Skip to content

Commit 6dfcc08

Browse files
jakubuidclaude
andcommitted
feat(tvf): collect transaction hashes for stellar_signXDR and stellar_signAndSubmitXDR
Flutter port of WalletConnect/walletconnect-monorepo#7318. - stellar_signAndSubmitXDR: extract tx_hash from the wallet response. - stellar_signXDR: compute the hash dependency-free from the base64 TransactionEnvelope XDR as sha256(network_id || envelope_type || transaction_body) using the pointycastle digest already in use. Supports V0, V1 and fee-bump envelopes; the trailing DecoratedSignature array is located by a validated scan rather than parsing the full transaction schema. The network passphrase is bound to the session's CAIP-2 chainId (from pendingTVFRequests), never trusted from the request payload; defaults to pubnet. Test vectors are real pubnet/testnet transactions fetched from Horizon, identical to the vectors in the JS, Kotlin and Swift PRs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7fe4e09 commit 6dfcc08

4 files changed

Lines changed: 280 additions & 0 deletions

File tree

packages/reown_core/lib/models/tvf_data.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,5 +74,8 @@ class TVFData {
7474
// SUI
7575
'sui_signTransaction',
7676
'sui_signAndExecuteTransaction',
77+
// Stellar
78+
'stellar_signXDR',
79+
'stellar_signAndSubmitXDR',
7780
];
7881
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import 'dart:convert';
2+
import 'dart:typed_data';
3+
import 'package:pointycastle/digests/sha256.dart';
4+
5+
class StellarChainUtils {
6+
static const _pubnetPassphrase = 'Public Global Stellar Network ; September 2015';
7+
static const _testnetPassphrase = 'Test SDF Network ; September 2015';
8+
9+
// XDR EnvelopeType discriminants
10+
static const _envelopeTypeTxV0 = 0;
11+
static const _envelopeTypeTx = 2;
12+
static const _envelopeTypeTxFeeBump = 5;
13+
14+
// DecoratedSignature with an ed25519 signature: hint (4) + length (4, =64) + signature (64)
15+
static const _decoratedSignatureLength = 72;
16+
static const _ed25519SignatureLength = 64;
17+
static const _maxEnvelopeSignatures = 20;
18+
19+
/// Computes the Stellar transaction hash from a base64-encoded, signed
20+
/// TransactionEnvelope XDR as sha256(network_id || envelope_type || transaction_body).
21+
/// Signatures are computed over the hash, so the trailing signature array is
22+
/// stripped rather than hashed. For fee-bump envelopes this yields the
23+
/// canonical fee-bump hash.
24+
///
25+
/// [signedXdr] base64-encoded TransactionEnvelope XDR (V0, V1 or fee-bump).
26+
/// [chainId] CAIP-2 chain id (`stellar:pubnet` / `stellar:testnet`), defaults to pubnet.
27+
/// Returns the lowercase hex transaction hash (64 chars).
28+
static String getStellarTxHashFromSignedXdr(String signedXdr, {String? chainId}) {
29+
final bytes = base64.decode(signedXdr);
30+
if (bytes.length < 8) {
31+
throw ArgumentError('Stellar envelope too short');
32+
}
33+
34+
final discriminant = _readUint32BE(bytes, 0);
35+
final int envelopeType;
36+
final int bodyStart;
37+
switch (discriminant) {
38+
// V0 transactions are hashed as ENVELOPE_TYPE_TX over the envelope bytes
39+
// INCLUDING the leading 4 zero bytes - they double as the legacy
40+
// AccountID key-type tag
41+
case _envelopeTypeTxV0:
42+
envelopeType = _envelopeTypeTx;
43+
bodyStart = 0;
44+
case _envelopeTypeTx:
45+
envelopeType = _envelopeTypeTx;
46+
bodyStart = 4;
47+
case _envelopeTypeTxFeeBump:
48+
envelopeType = _envelopeTypeTxFeeBump;
49+
bodyStart = 4;
50+
default:
51+
throw ArgumentError('Unsupported Stellar envelope type: $discriminant');
52+
}
53+
54+
final signatureArrayOffset = _findSignatureArrayOffset(bytes);
55+
56+
final reference = (chainId ?? 'stellar:pubnet').split(':').last;
57+
final String passphrase;
58+
switch (reference) {
59+
case 'pubnet':
60+
passphrase = _pubnetPassphrase;
61+
case 'testnet':
62+
passphrase = _testnetPassphrase;
63+
default:
64+
throw ArgumentError('Unknown Stellar network: $chainId');
65+
}
66+
67+
final networkId = _sha256(Uint8List.fromList(utf8.encode(passphrase)));
68+
final payload = Uint8List.fromList([
69+
...networkId,
70+
0, 0, 0, envelopeType,
71+
...bytes.sublist(bodyStart, signatureArrayOffset),
72+
]);
73+
74+
final hash = _sha256(payload);
75+
return hash.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
76+
}
77+
78+
/// Locates the start of the trailing `DecoratedSignature signatures<20>` XDR
79+
/// array without parsing the transaction body. Assumes ed25519 signatures
80+
/// (fixed 72-byte entries), which is what the WalletConnect Stellar RPC spec
81+
/// mandates wallets emit.
82+
static int _findSignatureArrayOffset(Uint8List bytes) {
83+
for (var signatureCount = 0; signatureCount <= _maxEnvelopeSignatures; signatureCount++) {
84+
final offset = bytes.length - 4 - _decoratedSignatureLength * signatureCount;
85+
if (offset < 4) break;
86+
if (_readUint32BE(bytes, offset) != signatureCount) continue;
87+
88+
var isValid = true;
89+
for (var i = 0; i < signatureCount; i++) {
90+
final entryOffset = offset + 4 + _decoratedSignatureLength * i;
91+
// each entry's signature length field must be exactly 64 (ed25519)
92+
if (_readUint32BE(bytes, entryOffset + 4) != _ed25519SignatureLength) {
93+
isValid = false;
94+
break;
95+
}
96+
}
97+
if (isValid) return offset;
98+
}
99+
throw ArgumentError('Could not locate Stellar envelope signature array');
100+
}
101+
102+
static int _readUint32BE(Uint8List bytes, int offset) {
103+
return (bytes[offset] << 24) |
104+
(bytes[offset + 1] << 16) |
105+
(bytes[offset + 2] << 8) |
106+
bytes[offset + 3];
107+
}
108+
109+
static Uint8List _sha256(Uint8List data) {
110+
return SHA256Digest().process(data);
111+
}
112+
}

packages/reown_sign/lib/sign_engine.dart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import 'package:reown_core/reown_core.dart';
1111
import 'package:reown_core/store/i_generic_store.dart';
1212
import 'package:reown_core/utils/algorand_utils.dart';
1313
import 'package:reown_core/utils/near_utils.dart';
14+
import 'package:reown_core/utils/stellar_utils.dart';
1415
import 'package:reown_core/utils/sui_utils.dart';
1516

1617
import 'package:reown_sign/reown_sign.dart';
@@ -2970,6 +2971,37 @@ class ReownSign implements IReownSign {
29702971
core.logger.e('[$runtimeType] _tvf data: stacks, $e');
29712972
}
29722973
return null;
2974+
case 'stellar':
2975+
try {
2976+
final result = (response.result as Map<String, dynamic>);
2977+
// stellar_signAndSubmitXDR responses carry the hash directly
2978+
final txHash = ReownCoreUtils.recursiveSearchForMapKey(
2979+
result,
2980+
'tx_hash',
2981+
);
2982+
if (txHash != null) {
2983+
return <String>[txHash.toString()];
2984+
}
2985+
// stellar_signXDR responses carry only the signed envelope, so the
2986+
// hash is computed from it. The network passphrase is bound to the
2987+
// session's CAIP-2 chain, never trusted from the request payload.
2988+
final signedXdr = ReownCoreUtils.recursiveSearchForMapKey(
2989+
result,
2990+
'signedXDR',
2991+
);
2992+
if (signedXdr != null) {
2993+
final id = response.id;
2994+
final chainId = pendingTVFRequests[id]?.chainId;
2995+
final computedHash = StellarChainUtils.getStellarTxHashFromSignedXdr(
2996+
signedXdr.toString(),
2997+
chainId: chainId,
2998+
);
2999+
return <String>[computedHash];
3000+
}
3001+
} catch (e) {
3002+
core.logger.e('[$runtimeType] _tvf data: stellar, $e');
3003+
}
3004+
return null;
29733005
case 'near':
29743006
try {
29753007
final result = NearChainUtils.parseResponse(response.result);

packages/reown_sign/test/tvf_collection_test.dart

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:flutter_test/flutter_test.dart';
2+
import 'package:reown_core/models/tvf_data.dart';
23
import 'package:reown_core/reown_core.dart';
34
import 'package:reown_core/store/generic_store.dart';
45
import 'package:reown_sign/reown_sign.dart';
@@ -1103,5 +1104,137 @@ void main() {
11031104
expect(hashes, isNull);
11041105
});
11051106
});
1107+
1108+
group('Direct Method Testing - collectHashes - Stellar', () {
1109+
// Stellar test vectors are real transactions fetched from Horizon
1110+
// (expected hash == the `hash` field of `GET /transactions/{hash}`).
1111+
const pubnetV1Xdr =
1112+
'AAAAAgAAAACutgsH0wwp9iT1V1zWE8jbQAm7JNeTEx4zdvWD4Jtk8wAAAGQDymekAAAAHAAAAAEAAAAAAAAAAAAAAABqguWuAAAAAAAAAAEAAAAAAAAAAQAAAACutgsH0wwp9iT1V1zWE8jbQAm7JNeTEx4zdvWD4Jtk8wAAAAAAAAAAAJiWgAAAAAAAAAAB4Jtk8wAAAECrfMK7BzVXCay0QnEItO7dJ8Ix2wGaMnFfbWHW1tE6cezMinDXiDtlVBwoK2GjAbrE0h+eGDjDqWWaRS1XDrwE';
1113+
const pubnetV1Hash =
1114+
'628ef4f404cba337f757a640260984830728c92101af0a051fb59fc8c79521c6';
1115+
1116+
const pubnetFeeBumpXdr =
1117+
'AAAABQAAAAA0mMHF8QGzwsMRBhe9i8PSIqxjNjKyQMyXZODBAdhAUwAAAAAAA5+BAAAAAgAAAAA6Hd6p+AA5GTO2bJqKN/hbBfWcOh2Ow8cnTY3iIFFVPAADnrgDoCvmAAAZVgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAADod3qn4ADkZM7Zsmoo3+FsF9Zw6HY7DxydNjeIgUVU8AAAAGAAAAAAAAAAB1/5EvQrxHWArEJHy9KH03yEtRE0DIeoyrbPMHLurCgQAAAAEd29yawAAAAMAAAASAAAAAAAAAAA6Hd6p+AA5GTO2bJqKN/hbBfWcOh2Ow8cnTY3iIFFVPAAAAA0AAAAgAAAAjpSFVoEyx/Ev5d2V/desEr+GEqMyXKvrs5wXFqwAAAAFAAAAAAIrSjwAAAAAAAAAAQAAAAAAAAABAAAAB9ssFCkNSWTjgF8lJ90TKTm6X7P8ysVrML+rj9CRARYnAAAAAwAAAAYAAAAB1/5EvQrxHWArEJHy9KH03yEtRE0DIeoyrbPMHLurCgQAAAAQAAAAAQAAAAIAAAAPAAAABUJsb2NrAAAAAAAAAwACsj8AAAAAAAAABgAAAAHX/kS9CvEdYCsQkfL0ofTfIS1ETQMh6jKts8wcu6sKBAAAABAAAAABAAAAAwAAAA8AAAAEUGFpbAAAABIAAAAAAAAAADod3qn4ADkZM7Zsmoo3+FsF9Zw6HY7DxydNjeIgUVU8AAAAAwACsj8AAAAAAAAABgAAAAHX/kS9CvEdYCsQkfL0ofTfIS1ETQMh6jKts8wcu6sKBAAAABQAAAABABvFygAAAAAAAAXIAAAAAAADnrgAAAABIFFVPAAAAEBbcalx54eMMHwWJz7tzgOoxIVmMl4pexbgwTLzxnyMtAhZ2nZlsF18jIMDBaubSNSNPi4YRHkSajpyOcdZOzsCAAAAAAAAAAEB2EBTAAAAQDOlpIeUB73BImAZJCSAt0cuKZXHlKG+TJ+j+fCeFe9bDc5wHzMlDjU6YlB6geCuxRi7QwTq4RgxrOIJ9HICfg8=';
1118+
const pubnetFeeBumpHash =
1119+
'5906453d5a367b4a8a1af9bbbc934904841718ec1ca1345874904e15f97bf83b';
1120+
1121+
const testnetV1Xdr =
1122+
'AAAAAgAAAAAJDqKNhO2/XZAvmR1Wynm2lxfIUQwB6TDzqNVOlQgQTgAAm74AJGh0ABKiOwAAAAEAAAAAAAAAAAAAAABqgthtAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAABmtIg9IzJFxPz3yuidCMj3LiE2SB3XYOl8DvtohHRPVAAAAAJc2V0X3ByaWNlAAAAAAAAAwAAABIAAAAAAAAAAAkOoo2E7b9dkC+ZHVbKebaXF8hRDAHpMPOo1U6VCBBOAAAADwAAAAZFVEhVU0QAAAAAAAoAAAAAAAAAAAAAAARomVswAAAAAQAAAAAAAAAAAAAAAZrSIPSMyRcT898ronQjI9y4hNkgd12DpfA77aIR0T1QAAAACXNldF9wcmljZQAAAAAAAAMAAAASAAAAAAAAAAAJDqKNhO2/XZAvmR1Wynm2lxfIUQwB6TDzqNVOlQgQTgAAAA8AAAAGRVRIVVNEAAAAAAAKAAAAAAAAAAAAAAAEaJlbMAAAAAAAAAABAAAAAAAAAAQAAAAGAAAAAcbgeSm1T8h+V5r+/0u+qUVZIopr5hDeGKj1+u37faSgAAAAEAAAAAEAAAADAAAADwAAAAdIYXNSb2xlAAAAABIAAAAAAAAAAAkOoo2E7b9dkC+ZHVbKebaXF8hRDAHpMPOo1U6VCBBOAAAADwAAAAZPUkFDTEUAAAAAAAEAAAAGAAAAAcbgeSm1T8h+V5r+/0u+qUVZIopr5hDeGKj1+u37faSgAAAAFAAAAAEAAAAHve3Sc2JfmD0Hu+w96oFCzEX1XxFR0uE/NeSf2Vqmia8AAAAH4IWMslKp5yCKjCiGPIRV6yV0LdrLOEfRrCRSwjur0G8AAAABAAAABgAAAAGa0iD0jMkXE/PfK6J0IyPcuITZIHddg6XwO+2iEdE9UAAAABQAAAABADikCAAAAAAAAAJUAAAAAAAATa0AAAABlQgQTgAAAEDSMm2vdKNsB2TCk+Pbb6vSYgq6Zd5F0E4H5BfMi4lwWEcYFAq2Mp+e12wr1qU+Ni7+2BTqZUkb+uK7lVM8PqkN';
1123+
const testnetV1Hash =
1124+
'c9b2d35ab15c055acb422872a8f1680a84ad6b8ad0d56271cfb74c083edf2007';
1125+
1126+
test('should compute hash for stellar_signXDR on pubnet', () {
1127+
// Arrange
1128+
const id = 500;
1129+
signEngine.pendingTVFRequests[id] = const TVFData(
1130+
rpcMethods: ['stellar_signXDR'],
1131+
chainId: 'stellar:pubnet',
1132+
);
1133+
final response = JsonRpcResponse(
1134+
id: id,
1135+
result: {
1136+
'signedXDR': pubnetV1Xdr,
1137+
'signerAddress':
1138+
'stellar:pubnet:GCXLMCYH2MGCT5RE6VLVZVQTZDNUACN3ETLZGEY6GN3PLA7ATNSPGGJH',
1139+
},
1140+
);
1141+
1142+
// Act
1143+
final hashes = signEngine.collectHashes('stellar', response);
1144+
1145+
// Assert
1146+
expect(hashes, equals([pubnetV1Hash]));
1147+
});
1148+
1149+
test('should default to pubnet for stellar_signXDR without pending request', () {
1150+
// Arrange
1151+
final response = JsonRpcResponse(
1152+
id: 501,
1153+
result: {'signedXDR': pubnetV1Xdr},
1154+
);
1155+
1156+
// Act
1157+
final hashes = signEngine.collectHashes('stellar', response);
1158+
1159+
// Assert
1160+
expect(hashes, equals([pubnetV1Hash]));
1161+
});
1162+
1163+
test('should compute canonical fee-bump hash for a fee-bump envelope', () {
1164+
// Arrange
1165+
const id = 502;
1166+
signEngine.pendingTVFRequests[id] = const TVFData(
1167+
rpcMethods: ['stellar_signXDR'],
1168+
chainId: 'stellar:pubnet',
1169+
);
1170+
final response = JsonRpcResponse(
1171+
id: id,
1172+
result: {'signedXDR': pubnetFeeBumpXdr},
1173+
);
1174+
1175+
// Act
1176+
final hashes = signEngine.collectHashes('stellar', response);
1177+
1178+
// Assert — H_fb, the hash explorers index, not the inner tx hash
1179+
expect(hashes, equals([pubnetFeeBumpHash]));
1180+
});
1181+
1182+
test('should compute hash for stellar_signXDR on testnet', () {
1183+
// Arrange
1184+
const id = 503;
1185+
signEngine.pendingTVFRequests[id] = const TVFData(
1186+
rpcMethods: ['stellar_signXDR'],
1187+
chainId: 'stellar:testnet',
1188+
);
1189+
final response = JsonRpcResponse(
1190+
id: id,
1191+
result: {'signedXDR': testnetV1Xdr},
1192+
);
1193+
1194+
// Act
1195+
final hashes = signEngine.collectHashes('stellar', response);
1196+
1197+
// Assert
1198+
expect(hashes, equals([testnetV1Hash]));
1199+
});
1200+
1201+
test('should extract tx_hash for stellar_signAndSubmitXDR', () {
1202+
// Arrange
1203+
final response = JsonRpcResponse(
1204+
id: 504,
1205+
result: {
1206+
'tx_hash':
1207+
'6da5298ae2b4fd1567fa3f760e66c9fb9014e3ac72bf48af1ad8120f8423b961',
1208+
'signedXDR': 'AAAAAg==',
1209+
'successful': true,
1210+
},
1211+
);
1212+
1213+
// Act
1214+
final hashes = signEngine.collectHashes('stellar', response);
1215+
1216+
// Assert
1217+
expect(
1218+
hashes,
1219+
equals([
1220+
'6da5298ae2b4fd1567fa3f760e66c9fb9014e3ac72bf48af1ad8120f8423b961',
1221+
]),
1222+
);
1223+
});
1224+
1225+
test('should return null for a malformed stellar envelope', () {
1226+
// Arrange
1227+
final response = JsonRpcResponse(
1228+
id: 505,
1229+
result: {'signedXDR': 'q83vASNFZ4mrze8BI0VniavN7wEjRWeJq83vAQ=='},
1230+
);
1231+
1232+
// Act
1233+
final hashes = signEngine.collectHashes('stellar', response);
1234+
1235+
// Assert
1236+
expect(hashes, isNull);
1237+
});
1238+
});
11061239
});
11071240
}

0 commit comments

Comments
 (0)