Skip to content

Commit 22a858c

Browse files
authored
Merge pull request #346 from reown-com/feat/stellar-tvf
feat(tvf): collect transaction hashes for stellar_signXDR and stellar_signAndSubmitXDR
2 parents 900f4b3 + 313c272 commit 22a858c

18 files changed

Lines changed: 453 additions & 135 deletions

Sources/WalletConnectRelay/tvf/AlgorandTVFCollector.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,8 @@ class AlgorandTVFCollector: ChainTVFCollector {
4242
underlyingValue = response.value
4343
}
4444

45-
// Extract the "result" field from JSON-RPC response structure
46-
guard let responseDict = underlyingValue as? [String: Any],
47-
let signedTxnsBase64 = responseDict["result"] as? [String] else {
45+
// The result value is the array of base64 signed txns directly
46+
guard let signedTxnsBase64 = underlyingValue as? [String] else {
4847
return nil
4948
}
5049

Sources/WalletConnectRelay/tvf/BitcoinTVFCollector.swift

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,23 +45,18 @@ class BitcoinTVFCollector: ChainTVFCollector {
4545
return nil
4646
}
4747

48-
// Extract from result wrapper (always under "result" key in JSON-RPC)
49-
if let result = try? anycodable.get([String: AnyCodable].self),
50-
let resultValue = result["result"] {
51-
52-
// Try to decode as BitcoinTransferResult
53-
if let transferResult = try? resultValue.get(BitcoinTransferResult.self) {
54-
return [transferResult.txid]
55-
}
56-
57-
// Alternative: try to extract txid directly from the result map if the above fails
58-
if let resultMap = try? resultValue.get([String: AnyCodable].self),
59-
let txidValue = resultMap["txid"],
60-
let txid = try? txidValue.get(String.self) {
61-
return [txid]
62-
}
48+
// Decode directly from the unwrapped result value
49+
if let t = try? anycodable.get(BitcoinTransferResult.self) {
50+
return [t.txid]
6351
}
64-
52+
53+
// Alternative: try to extract txid directly from the result map if the above fails
54+
if let map = try? anycodable.get([String: AnyCodable].self),
55+
let v = map["txid"],
56+
let txid = try? v.get(String.self) {
57+
return [txid]
58+
}
59+
6560
return nil
6661
}
6762
}

Sources/WalletConnectRelay/tvf/CosmosTVFCollector.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,8 @@ class CosmosTVFCollector: ChainTVFCollector {
2929
}
3030
return nil
3131
}
32-
// For signDirect we expect { result: { ... } }
33-
guard let wrapper = try? anycodable.get([String: AnyCodable].self),
34-
let resultAny = wrapper["result"],
35-
let result = try? resultAny.get([String: AnyCodable].self) else { return nil }
32+
// For signDirect the result value is the object directly
33+
guard let result = try? anycodable.get([String: AnyCodable].self) else { return nil }
3634

3735
if rpcMethod == Self.COSMOS_SIGN_DIRECT {
3836
return handleSignDirect(result)

Sources/WalletConnectRelay/tvf/HederaTVFCollector.swift

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,11 @@ class HederaTVFCollector: ChainTVFCollector {
5050
return nil
5151
}
5252

53-
// Extract from result wrapper (nested format)
54-
if let result = try? anycodable.get([String: AnyCodable].self),
55-
let resultValue = result["result"],
56-
let decoded = try? resultValue.get(HederaTransactionResult.self) {
53+
// Decode directly from the unwrapped result value
54+
if let decoded = try? anycodable.get(HederaTransactionResult.self) {
5755
return [decoded.transactionId]
5856
}
59-
57+
6058
return nil
6159
}
6260
}

Sources/WalletConnectRelay/tvf/StacksTVFCollector.swift

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,11 @@ class StacksTVFCollector: ChainTVFCollector {
4242
return nil
4343
}
4444

45-
// Extract from result wrapper (always under "result" key in JSON-RPC)
46-
if let result = try? anycodable.get([String: AnyCodable].self),
47-
let resultValue = result["result"] {
48-
49-
// Try to decode as StacksTransferResult
50-
if let transferResult = try? resultValue.get(StacksTransferResult.self) {
51-
return [transferResult.txid]
52-
}
45+
// Decode directly from the unwrapped result value
46+
if let transferResult = try? anycodable.get(StacksTransferResult.self) {
47+
return [transferResult.txid]
5348
}
54-
49+
5550
return nil
5651
}
5752
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import Foundation
2+
import CryptoKit
3+
4+
// MARK: - Supporting Models
5+
6+
struct StellarSignXDRResult: Codable {
7+
let signedXDR: String
8+
let signerAddress: String?
9+
}
10+
11+
struct StellarSignAndSubmitXDRResult: Codable {
12+
let tx_hash: String?
13+
let signedXDR: String?
14+
}
15+
16+
// MARK: - StellarTVFCollector
17+
18+
class StellarTVFCollector: ChainTVFCollector {
19+
// MARK: - Constants
20+
21+
static let STELLAR_SIGN_XDR = "stellar_signXDR"
22+
static let STELLAR_SIGN_AND_SUBMIT_XDR = "stellar_signAndSubmitXDR"
23+
24+
private static let pubnetPassphrase = "Public Global Stellar Network ; September 2015"
25+
private static let testnetPassphrase = "Test SDF Network ; September 2015"
26+
27+
// XDR EnvelopeType discriminants
28+
private static let envelopeTypeTxV0: UInt32 = 0
29+
private static let envelopeTypeTx: UInt32 = 2
30+
private static let envelopeTypeTxFeeBump: UInt32 = 5
31+
32+
// DecoratedSignature with an ed25519 signature: hint (4) + length (4, =64) + signature (64)
33+
private static let decoratedSignatureLength = 72
34+
private static let ed25519SignatureLength: UInt32 = 64
35+
private static let maxEnvelopeSignatures = 20
36+
37+
// MARK: - Supported Methods
38+
39+
private var supportedMethods: [String] {
40+
[Self.STELLAR_SIGN_XDR, Self.STELLAR_SIGN_AND_SUBMIT_XDR]
41+
}
42+
43+
func supportsMethod(_ method: String) -> Bool {
44+
return supportedMethods.contains(method)
45+
}
46+
47+
// MARK: - Implementation
48+
49+
func extractContractAddresses(rpcMethod: String, rpcParams: AnyCodable) -> [String]? {
50+
// Stellar doesn't extract contract addresses for TVF in this implementation
51+
return nil
52+
}
53+
54+
func parseTxHashes(rpcMethod: String, rpcResult: RPCResult?, rpcParams: AnyCodable?) -> [String]? {
55+
// If rpcResult is nil or is an error, we can't parse anything
56+
guard let rpcResult = rpcResult, case .response(let anycodable) = rpcResult else {
57+
return nil
58+
}
59+
60+
// Only process Stellar transaction methods
61+
guard supportedMethods.contains(rpcMethod) else {
62+
return nil
63+
}
64+
65+
// `rpcResult` already holds the unwrapped JSON-RPC `result` value — the `result`
66+
// key is stripped during RPCResponse decoding — so decode directly off `anycodable`,
67+
// matching the Solana/EVM collectors and the JS reference implementation.
68+
switch rpcMethod {
69+
case Self.STELLAR_SIGN_AND_SUBMIT_XDR:
70+
if let result = try? anycodable.get(StellarSignAndSubmitXDRResult.self),
71+
let txHash = result.tx_hash {
72+
return [txHash]
73+
}
74+
return nil
75+
76+
case Self.STELLAR_SIGN_XDR:
77+
guard let result = try? anycodable.get(StellarSignXDRResult.self) else {
78+
return nil
79+
}
80+
let chain = extractChain(from: rpcParams)
81+
return Self.computeTransactionHash(signedXDR: result.signedXDR, chain: chain).map { [$0] }
82+
83+
default:
84+
return nil
85+
}
86+
}
87+
88+
private func extractChain(from rpcParams: AnyCodable?) -> String? {
89+
guard let rpcParams = rpcParams,
90+
let params = try? rpcParams.get([String: AnyCodable].self),
91+
let chainAny = params["chain"],
92+
let chain = try? chainAny.get(String.self) else {
93+
return nil
94+
}
95+
return chain
96+
}
97+
98+
// MARK: - Hash Computation
99+
100+
/// Computes the Stellar transaction hash from a base64-encoded, signed TransactionEnvelope XDR
101+
/// as `sha256(network_id || envelope_type || transaction_body)`. Signatures are computed over
102+
/// the hash, so the trailing signature array is stripped rather than hashed. For fee-bump
103+
/// envelopes this yields the canonical fee-bump hash.
104+
///
105+
/// - Parameters:
106+
/// - signedXDR: base64-encoded TransactionEnvelope XDR (V0, V1 or fee-bump)
107+
/// - chain: CAIP-2 chain id (`stellar:pubnet` / `stellar:testnet`), defaults to pubnet
108+
/// - Returns: lowercase hex transaction hash (64 chars), or nil for malformed envelopes
109+
static func computeTransactionHash(signedXDR: String, chain: String?) -> String? {
110+
guard let bytes = Data(base64Encoded: signedXDR), bytes.count >= 8 else {
111+
return nil
112+
}
113+
114+
let discriminant = readUInt32BE(bytes, 0)
115+
let envelopeType: UInt32
116+
let bodyStart: Int
117+
switch discriminant {
118+
case envelopeTypeTxV0:
119+
// V0 transactions are hashed as ENVELOPE_TYPE_TX over the envelope bytes INCLUDING
120+
// the leading 4 zero bytes - they double as the legacy AccountID key-type tag
121+
envelopeType = envelopeTypeTx
122+
bodyStart = 0
123+
case envelopeTypeTx:
124+
envelopeType = envelopeTypeTx
125+
bodyStart = 4
126+
case envelopeTypeTxFeeBump:
127+
envelopeType = envelopeTypeTxFeeBump
128+
bodyStart = 4
129+
default:
130+
return nil
131+
}
132+
133+
guard let signatureArrayOffset = findSignatureArrayOffset(bytes) else {
134+
return nil
135+
}
136+
137+
let reference = chain?.components(separatedBy: ":").last ?? "pubnet"
138+
let passphrase: String
139+
switch reference {
140+
case "pubnet": passphrase = pubnetPassphrase
141+
case "testnet": passphrase = testnetPassphrase
142+
default: return nil
143+
}
144+
145+
let networkId = Data(SHA256.hash(data: Data(passphrase.utf8)))
146+
var payload = networkId
147+
payload.append(contentsOf: [0, 0, 0, UInt8(envelopeType)])
148+
payload.append(bytes.subdata(in: bodyStart..<signatureArrayOffset))
149+
150+
return SHA256.hash(data: payload).map { String(format: "%02x", $0) }.joined()
151+
}
152+
153+
/// Locates the start of the trailing `DecoratedSignature signatures<20>` XDR array without
154+
/// parsing the transaction body. Assumes ed25519 signatures (fixed 72-byte entries), which is
155+
/// what the WalletConnect Stellar RPC spec mandates wallets emit.
156+
private static func findSignatureArrayOffset(_ bytes: Data) -> Int? {
157+
// Scan from the maximum count downward: a real multi-signature array must be found
158+
// before the vacuously-matching zero count, which would otherwise win whenever a
159+
// signature happens to end in four zero bytes.
160+
for signatureCount in stride(from: maxEnvelopeSignatures, through: 0, by: -1) {
161+
let offset = bytes.count - 4 - decoratedSignatureLength * signatureCount
162+
if offset < 4 { continue }
163+
if readUInt32BE(bytes, offset) != UInt32(signatureCount) { continue }
164+
165+
var isValid = true
166+
for i in 0..<signatureCount {
167+
let entryOffset = offset + 4 + decoratedSignatureLength * i
168+
// each entry's signature length field must be exactly 64 (ed25519)
169+
if readUInt32BE(bytes, entryOffset + 4) != ed25519SignatureLength {
170+
isValid = false
171+
break
172+
}
173+
}
174+
if isValid { return offset }
175+
}
176+
return nil
177+
}
178+
179+
private static func readUInt32BE(_ bytes: Data, _ offset: Int) -> UInt32 {
180+
let index = bytes.startIndex + offset
181+
return (UInt32(bytes[index]) << 24)
182+
| (UInt32(bytes[index + 1]) << 16)
183+
| (UInt32(bytes[index + 2]) << 8)
184+
| UInt32(bytes[index + 3])
185+
}
186+
}

Sources/WalletConnectRelay/tvf/SuiTVFCollector.swift

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -57,28 +57,20 @@ class SuiTVFCollector: ChainTVFCollector {
5757
return nil
5858
}
5959

60-
// Extract from result wrapper (always under "result" key in JSON-RPC)
61-
if let result = try? anycodable.get([String: AnyCodable].self),
62-
let resultValue = result["result"] {
63-
64-
if rpcMethod == Self.SUI_SIGN_AND_EXECUTE_TRANSACTION {
65-
// For sui_signAndExecuteTransaction, extract digest directly
66-
if let signAndExecuteResult = try? resultValue.get(SuiSignAndExecuteTransactionResult.self) {
67-
return [signAndExecuteResult.digest]
68-
}
69-
} else if rpcMethod == Self.SUI_SIGN_TRANSACTION {
70-
// For sui_signTransaction, we need to calculate the digest from transactionBytes
71-
if let signResult = try? resultValue.get(SuiSignTransactionResult.self) {
72-
// In a real implementation, we would calculate the transaction digest
73-
// from the transaction bytes using the Blake2b hash algorithm
74-
// Here we're using the transactionBytes as a placeholder
75-
if let digest = calculateTransactionDigest(from: signResult.transactionBytes) {
76-
return [digest]
77-
}
78-
}
60+
// Decode directly from the unwrapped result value
61+
if rpcMethod == Self.SUI_SIGN_AND_EXECUTE_TRANSACTION {
62+
// For sui_signAndExecuteTransaction, extract digest directly
63+
if let r = try? anycodable.get(SuiSignAndExecuteTransactionResult.self) {
64+
return [r.digest]
65+
}
66+
} else if rpcMethod == Self.SUI_SIGN_TRANSACTION {
67+
// For sui_signTransaction, we need to calculate the digest from transactionBytes
68+
if let r = try? anycodable.get(SuiSignTransactionResult.self),
69+
let digest = calculateTransactionDigest(from: r.transactionBytes) {
70+
return [digest]
7971
}
8072
}
81-
73+
8274
return nil
8375
}
8476

Sources/WalletConnectRelay/tvf/TVFCollector.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ public class TVFCollector: TVFCollectorProtocol {
3838
StacksTVFCollector(),
3939
SuiTVFCollector(),
4040
PolkadotTVFCollector(),
41-
TonTVFCollector()
41+
TonTVFCollector(),
42+
StellarTVFCollector()
4243
]
4344
}
4445

Sources/WalletConnectRelay/tvf/XRPLTVFCollector.swift

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,11 @@ class XRPLTVFCollector: ChainTVFCollector {
5555
return nil
5656
}
5757

58-
// Extract from result wrapper (nested format)
59-
if let result = try? anycodable.get([String: AnyCodable].self),
60-
let resultValue = result["result"],
61-
let decoded = try? resultValue.get(XRPLSignTransactionResult.self) {
58+
// Decode directly from the unwrapped result value
59+
if let decoded = try? anycodable.get(XRPLSignTransactionResult.self) {
6260
return [decoded.tx_json.hash]
6361
}
64-
62+
6563
return nil
6664
}
6765
}

Tests/RelayerTests/AlgorandTVFCollectorTests.swift

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,8 @@ final class AlgorandTVFCollectorTests: XCTestCase {
3030
"gqNzaWfEQNGPgbxS9pTu0sTikT3cJVO48WFltc8MM8meFR+aAnGwOo3FO+0nFkAludT0jNqHRM6E65gW6k/m92sHVCxVnQWjdHhuiaNhbXTOAAehIKNmZWXNA+iiZnbOAv0CO6NnZW6sbWFpbm5ldC12MS4womdoxCDAYcTY/B293tLXYEvkVo4/bQQZh6w3veS2ILWrOSSK36Jsds4C/QYjo3JjdsQgeqRNTBEXudHx2kO9Btq289aRzj5DlNUw0jwX9KEnaZqjc25kxCDH1s5tvgARbjtHceUG07Sj5IDfqzn7Zwx0P+XuvCYMz6R0eXBlo3BheQ=="
3131
]
3232

33-
// Create proper nested RPCResult with JSON-RPC format
34-
let nestedData = ["result": signedTxnsBase64]
35-
let rpcResult = RPCResult.response(AnyCodable(any: nestedData))
33+
// Production shape: RPCResult.response holds the already-unwrapped result value (the raw array)
34+
let rpcResult = RPCResult.response(AnyCodable(any: signedTxnsBase64))
3635

3736
// Test hash extraction
3837
let txHashes = algorandCollector.parseTxHashes(
@@ -53,9 +52,8 @@ final class AlgorandTVFCollectorTests: XCTestCase {
5352
let signedTxnsBase64 = [
5453
"gqNzaWfEQNGPgbxS9pTu0sTikT3cJVO48WFltc8MM8meFR+aAnGwOo3FO+0nFkAludT0jNqHRM6E65gW6k/m92sHVCxVnQWjdHhuiaNhbXTOAAehIKNmZWXNA+iiZnbOAv0CO6NnZW6sbWFpbm5ldC12MS4womdoxCDAYcTY/B293tLXYEvkVo4/bQQZh6w3veS2ILWrOSSK36Jsds4C/QYjo3JjdsQgeqRNTBEXudHx2kO9Btq289aRzj5DlNUw0jwX9KEnaZqjc25kxCDH1s5tvgARbjtHceUG07Sj5IDfqzn7Zwx0P+XuvCYMz6R0eXBlo3BheQ=="
5554
]
56-
// Create proper nested RPCResult with JSON-RPC format
57-
// The structure is { "result": ["base64encodedsignedtxn"] }
58-
let rpcResult = makeResponse(["result": signedTxnsBase64])
55+
// Production shape: RPCResult.response holds the already-unwrapped result value (the raw array)
56+
let rpcResult = makeResponse(signedTxnsBase64)
5957

6058
// Act
6159
let result = algorandCollector.parseTxHashes(rpcMethod: rpcMethod, rpcResult: rpcResult)

0 commit comments

Comments
 (0)