forked from StellarSend/backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtx_hash.rs
More file actions
209 lines (180 loc) · 8.06 KB
/
Copy pathtx_hash.rs
File metadata and controls
209 lines (180 loc) · 8.06 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
use crate::error::{AppError, AppResult};
use sha2::{Digest, Sha256};
use stellar_xdr::curr::{
Hash, Limits, ReadXdr, TransactionEnvelope, TransactionSignaturePayload,
TransactionSignaturePayloadTaggedTransaction, WriteXdr,
};
/// Computes the canonical Stellar transaction hash for an already-signed
/// XDR envelope, entirely locally — no Horizon round-trip needed. This is
/// the same hash Horizon reports back after submission, so it can be
/// persisted *before* ever calling Horizon and used later by
/// `ReconciliationService` to look up the true on-chain outcome of a batch
/// whose submission response was lost to a crash or a client-side timeout
/// (#30).
///
/// Per the Stellar protocol the hash covers `network_id || tagged_transaction`
/// only — signatures are excluded — so this never needs (and never sees)
/// any private key; it works purely from the already-signed envelope the
/// client posted to us.
///
/// Supports the two envelope shapes any current Stellar SDK produces:
/// standard (`Tx`) and fee-bump (`TxFeeBump`). The legacy pre-Protocol-13
/// `TxV0` shape is rejected — no SDK still emits it, and mapping it to the
/// current `Transaction` shape just to hash it would be speculative,
/// untested complexity for a format nothing in this system ever sends us.
pub fn compute_transaction_hash(signed_xdr: &str, network_passphrase: &str) -> AppResult<String> {
let envelope = TransactionEnvelope::from_xdr_base64(signed_xdr, Limits::none())
.map_err(|e| AppError::BadRequest(format!("Invalid signed_xdr: {e}")))?;
let tagged_transaction = match envelope {
TransactionEnvelope::Tx(v1) => TransactionSignaturePayloadTaggedTransaction::Tx(v1.tx),
TransactionEnvelope::TxFeeBump(fb) => {
TransactionSignaturePayloadTaggedTransaction::TxFeeBump(fb.tx)
}
TransactionEnvelope::TxV0(_) => {
return Err(AppError::BadRequest(
"signed_xdr uses the legacy TxV0 envelope format, which is not supported".into(),
));
}
};
let network_id = Hash(Sha256::digest(network_passphrase.as_bytes()).into());
let payload = TransactionSignaturePayload {
network_id,
tagged_transaction,
};
let payload_bytes = payload
.to_xdr(Limits::none())
.map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to encode tx payload: {e}")))?;
let hash: [u8; 32] = Sha256::digest(&payload_bytes).into();
Ok(hex::encode(hash))
}
#[cfg(test)]
mod tests {
use super::*;
use stellar_xdr::curr::{
Memo, MuxedAccount, Operation, OperationBody, PaymentOp, Preconditions, Transaction,
TransactionExt, TransactionV1Envelope, Uint256, VecM,
};
const NETWORK: &str = "Test SDF Network ; September 2015";
const SOURCE: &str = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI";
/// Builds a syntactically valid, unsigned `Transaction` with a single
/// native payment operation — enough to exercise hashing without
/// needing a real signing key. The destination reuses the same test
/// keypair as the source; it's dummy data purely to exercise XDR
/// encoding, not a real account.
fn sample_transaction() -> Transaction {
let source_id = stellar_strkey::ed25519::PublicKey::from_string(SOURCE)
.expect("valid test source account");
let source_account = MuxedAccount::Ed25519(Uint256(source_id.0));
Transaction {
source_account,
fee: 100,
seq_num: stellar_xdr::curr::SequenceNumber(1),
cond: Preconditions::None,
memo: Memo::None,
operations: vec![Operation {
source_account: None,
body: OperationBody::Payment(PaymentOp {
destination: MuxedAccount::Ed25519(Uint256(source_id.0)),
asset: stellar_xdr::curr::Asset::Native,
amount: 100_000_000,
}),
}]
.try_into()
.expect("single operation fits VecM"),
ext: TransactionExt::V0,
}
}
fn envelope_base64(tx: Transaction) -> String {
let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
tx,
// The hash excludes signatures entirely, so an empty signature
// list is fine for this test.
signatures: VecM::default(),
});
envelope
.to_xdr_base64(Limits::none())
.expect("encoding should succeed")
}
/// Independently recomputes the hash straight from the `Transaction` we
/// built (bypassing envelope parsing) so the test isn't just comparing
/// the function against itself.
fn expected_hash(tx: Transaction, passphrase: &str) -> String {
let network_id = Hash(Sha256::digest(passphrase.as_bytes()).into());
let payload = TransactionSignaturePayload {
network_id,
tagged_transaction: TransactionSignaturePayloadTaggedTransaction::Tx(tx),
};
let bytes = payload.to_xdr(Limits::none()).unwrap();
hex::encode(Sha256::digest(&bytes))
}
#[test]
fn computes_the_same_hash_as_an_independent_calculation() {
let tx = sample_transaction();
let b64 = envelope_base64(tx.clone());
let got = compute_transaction_hash(&b64, NETWORK).expect("should hash successfully");
let want = expected_hash(tx, NETWORK);
assert_eq!(got, want);
assert_eq!(got.len(), 64, "hash must be 64 hex chars (32 bytes)");
}
#[test]
fn different_network_passphrase_yields_a_different_hash() {
let tx = sample_transaction();
let b64 = envelope_base64(tx);
let mainnet_hash =
compute_transaction_hash(&b64, "Public Global Stellar Network ; September 2015")
.unwrap();
let testnet_hash = compute_transaction_hash(&b64, NETWORK).unwrap();
assert_ne!(mainnet_hash, testnet_hash);
}
#[test]
fn is_deterministic_for_the_same_input() {
let tx = sample_transaction();
let b64 = envelope_base64(tx);
let first = compute_transaction_hash(&b64, NETWORK).unwrap();
let second = compute_transaction_hash(&b64, NETWORK).unwrap();
assert_eq!(first, second);
}
#[test]
fn rejects_garbage_xdr() {
let err = compute_transaction_hash("not-valid-base64-xdr!!!", NETWORK).unwrap_err();
assert!(matches!(err, AppError::BadRequest(_)));
}
#[test]
fn rejects_empty_string() {
let err = compute_transaction_hash("", NETWORK).unwrap_err();
assert!(matches!(err, AppError::BadRequest(_)));
}
#[test]
fn fee_bump_envelope_hashes_via_the_feebump_tagged_transaction() {
use stellar_xdr::curr::{
FeeBumpTransaction, FeeBumpTransactionEnvelope, FeeBumpTransactionInnerTx,
};
let inner_tx = sample_transaction();
let inner_envelope = TransactionV1Envelope {
tx: inner_tx,
signatures: VecM::default(),
};
let source_id = stellar_strkey::ed25519::PublicKey::from_string(SOURCE).unwrap();
let fee_bump_tx = FeeBumpTransaction {
fee_source: MuxedAccount::Ed25519(Uint256(source_id.0)),
fee: 1000,
inner_tx: FeeBumpTransactionInnerTx::Tx(inner_envelope),
ext: stellar_xdr::curr::FeeBumpTransactionExt::V0,
};
let envelope = TransactionEnvelope::TxFeeBump(FeeBumpTransactionEnvelope {
tx: fee_bump_tx.clone(),
signatures: VecM::default(),
});
let b64 = envelope.to_xdr_base64(Limits::none()).unwrap();
let got = compute_transaction_hash(&b64, NETWORK).unwrap();
let network_id = Hash(Sha256::digest(NETWORK.as_bytes()).into());
let payload = TransactionSignaturePayload {
network_id,
tagged_transaction: TransactionSignaturePayloadTaggedTransaction::TxFeeBump(
fee_bump_tx,
),
};
let want = hex::encode(Sha256::digest(payload.to_xdr(Limits::none()).unwrap()));
assert_eq!(got, want);
}
}