Skip to content

Commit c509f46

Browse files
feat: embed network_id in signed attestations (#697)
Closes #578 ## Changes ### src/lib.rs - Add `NetworkIdMismatch = 62` to `RevoraError` — returned when an attestation's embedded network_id does not match the current chain. - Add `InvalidTransferParticipants = 63` — returned on self-transfer. - Add `SignedAttestation` struct with `network_id: BytesN<32>` (domain separator) and `digest: BytesN<32>` (pre-signed canonical hash). - Rewrite `transfer_with_attestation`: add dual-party auth (`from` + `issuer`), self-transfer guard, offering-level freeze check, whitelist check, and all 10 ordered guards documented in the function doc-comment. - Add `compute_attestation_digest` (read-only) — returns the canonical sha256(network_id || XDR(issuer) || XDR(namespace) || XDR(token) || XDR(from) || XDR(to) || u32be(amount_bps)) for the current chain. - Add `verify_attestation_digest` (read-only) — pre-flight check that rejects attestations whose network_id or digest does not match. Returns `Err(NetworkIdMismatch)` for both mismatch cases (fail-closed design). - Add private `build_attestation_digest` helper used by both public fns. ### src/test_transfer_with_attestation.rs - Update module doc-comment to include network-id test coverage table. - Add `make_signed_attestation` test helper. - Add 6 network-id domain separator tests: - `verify_attestation_correct_network_id` — golden path - `verify_attestation_mainnet_id_on_testnet_rejected` - `verify_attestation_testnet_id_on_mainnet_rejected` - `verify_attestation_unknown_network_id_rejected` - `verify_attestation_wrong_digest_rejected` - `attestation_compute_verify_round_trip` (no-aliasing property) ### docs/transfer-with-attestation.md - Document `SignedAttestation` struct and its two fields. - Document `compute_attestation_digest` and `verify_attestation_digest`. - Add digest-construction preimage spec (network_id || XDR fields). - Add security guarantees table (replay prevention, parameter binding, no-aliasing, read-only verification, fail-closed behaviour). - Add `NetworkIdMismatch` to the error reference table (code 62). - Add full test-coverage table for all 24 test cases. Co-authored-by: thlpkee20-wq <thlpkee20@gmail.com>
1 parent 3cdc3f6 commit c509f46

3 files changed

Lines changed: 847 additions & 205 deletions

File tree

docs/transfer-with-attestation.md

Lines changed: 199 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,26 @@ holders of a revenue-share offering. Both parties must co-sign the transaction,
99
This makes on-chain share handoffs a first-class operation while preserving the issuer's
1010
ability to enforce jurisdiction restrictions through blacklists and whitelists.
1111

12+
---
13+
1214
## Signature
1315

1416
```rust
1517
pub fn transfer_with_attestation(
1618
env: Env,
17-
issuer: Address, // Primary issuer of the offering
18-
namespace: Symbol, // Offering namespace
19-
token: Address, // Offering token
20-
from: Address, // Current shareholder; must provide auth
21-
to: Address, // Recipient; must provide auth
22-
shares_bps: u32, // Basis points to transfer (1–10000, must be > 0)
23-
category: Symbol, // Transfer category used for category-cap enforcement
19+
issuer: Address, // Primary issuer of the offering
20+
namespace: Symbol, // Offering namespace
21+
token: Address, // Offering token
22+
from: Address, // Current shareholder; must provide auth
23+
to: Address, // Recipient; must provide auth
24+
shares_bps: u32, // Basis points to transfer (1–10000, must be > 0)
2425
attest_hash: BytesN<32>, // 32-byte attestation hash for compliance
2526
network_id: BytesN<32>, // Ledger network identifier the attestation is bound to
2627
) -> Result<(), RevoraError>
2728
```
2829

30+
---
31+
2932
## Security Model
3033

3134
Ten guards are applied in strict order before any state is mutated:
@@ -34,7 +37,7 @@ Ten guards are applied in strict order before any state is mutated:
3437
|---|-------|--------------------|
3538
| 1 | Contract is not globally frozen or paused | `ContractFrozen` / `ContractPaused` |
3639
| 2 | `from` has authorized (`require_auth`) | host panic (non-catchable) |
37-
| 2 | `to` has authorized (`require_auth`) | host panic (non-catchable) |
40+
| 2 | `issuer` has authorized (`require_auth`) | host panic (non-catchable) |
3841
| 3 | `from != to` | `InvalidTransferParticipants` |
3942
| 10 | `shares_bps > 0` | `InvalidShareBps` |
4043
| 4 | Offering exists with matching primary issuer | `OfferingNotFound` |
@@ -46,11 +49,13 @@ Ten guards are applied in strict order before any state is mutated:
4649
| 10 | `network_id` matches `env.ledger().network_id()` | `NetworkIdMismatch` |
4750

4851
**Dual-party authorization** (Guard 2) is the primary peer-to-peer security invariant.
49-
Neither the sender nor the recipient can unilaterally move shares.
52+
Neither the sender nor the issuer can unilaterally move shares.
5053

5154
**Blacklist takes precedence** over the whitelist (Guard 6 fires before Guard 7). A
5255
blacklisted address is always excluded regardless of whitelist membership.
5356

57+
---
58+
5459
## Storage Invariant
5560

5661
A peer-to-peer transfer is a **pure redistribution**: the total BPS across all holders
@@ -60,6 +65,8 @@ Only the two `HolderShare` entries (for `from` and `to`) are updated atomically.
6065
This ensures that subsequent calls to `set_holder_share` see the correct running total
6166
and correctly enforce the per-offering 10 000 bps cap.
6267

68+
---
69+
6370
## Attestation Hash
6471

6572
The 32-byte `attest_hash` is emitted verbatim in the `xfer_att` event. The contract
@@ -73,6 +80,149 @@ The intended usage is for off-chain compliance tooling to store the hash of an a
7380
document (KYC confirmation, AML clearance, jurisdiction sign-off, etc.) so that the
7481
on-chain event log can be cross-referenced with the off-chain approval record.
7582

83+
---
84+
85+
## Network-Id Domain Separator (closes #578)
86+
87+
### Why network_id matters
88+
89+
An attestation must be **cryptographically bound to one specific Stellar network**.
90+
Without a domain separator an attestation produced and signed on testnet could be
91+
replayed on mainnet by any party who observed it on-chain or in transaction history.
92+
93+
The `network_id` domain separator makes cross-network replay impossible:
94+
95+
```
96+
testnet network_id = sha256("Test SDF Network ; September 2015")
97+
= cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472
98+
99+
mainnet network_id = sha256("Public Global Stellar Network ; September 2015")
100+
= e927f128742077640...b17d52d4 (different bytes)
101+
```
102+
103+
Because the `network_id` is included in the signed preimage, a testnet attestation
104+
produces a **different digest** than a mainnet attestation for identical parameters.
105+
106+
### `SignedAttestation` struct
107+
108+
```rust
109+
pub struct SignedAttestation {
110+
/// sha256 of the Stellar network passphrase — the domain separator.
111+
pub network_id: BytesN<32>,
112+
/// Pre-signed digest over (network_id || issuer || namespace || token
113+
/// || from || to || amount_bps).
114+
pub digest: BytesN<32>,
115+
}
116+
```
117+
118+
### Digest construction
119+
120+
Off-chain signers compute:
121+
122+
```text
123+
digest = sha256(
124+
network_id (32 bytes — env.ledger().network_id())
125+
|| XDR(issuer)
126+
|| XDR(namespace)
127+
|| XDR(token)
128+
|| XDR(from)
129+
|| XDR(to)
130+
|| amount_bps (4 bytes, big-endian u32)
131+
)
132+
```
133+
134+
Use `compute_attestation_digest` (read-only, no auth required) to obtain the expected
135+
digest for the current chain directly from the contract.
136+
137+
### `compute_attestation_digest`
138+
139+
```rust
140+
pub fn compute_attestation_digest(
141+
env: Env,
142+
issuer: Address,
143+
namespace: Symbol,
144+
token: Address,
145+
from: Address,
146+
to: Address,
147+
amount_bps: u32,
148+
) -> BytesN<32>
149+
```
150+
151+
Returns the canonical domain-separated digest for the current chain. Call this
152+
read-only before having the parties sign so you always use the correct preimage.
153+
154+
### `verify_attestation_digest`
155+
156+
```rust
157+
pub fn verify_attestation_digest(
158+
env: Env,
159+
attestation: SignedAttestation,
160+
issuer: Address,
161+
namespace: Symbol,
162+
token: Address,
163+
from: Address,
164+
to: Address,
165+
amount_bps: u32,
166+
) -> Result<(), RevoraError>
167+
```
168+
169+
Pre-flight validator for a `SignedAttestation`. Two checks are enforced:
170+
171+
1. **Network-id check** — `attestation.network_id` must equal `env.ledger().network_id()`.
172+
Fails with `NetworkIdMismatch` when the attestation was produced for a different chain.
173+
174+
2. **Digest check** — `attestation.digest` must equal the canonical preimage hash for
175+
the supplied parameters. Fails with `NetworkIdMismatch` if the digest is wrong.
176+
177+
Both failures return `NetworkIdMismatch` so callers cannot distinguish which check
178+
failed and cannot craft a targeted bypass attempt.
179+
180+
This function is **read-only**no state is written, no auth is required.
181+
182+
### Off-chain integration example
183+
184+
```rust
185+
// 1. Fetch the expected digest from the contract (read-only).
186+
let digest = client.compute_attestation_digest(
187+
&issuer, &namespace, &token, &from, &to, &amount_bps,
188+
);
189+
190+
// 2. Have the authorised compliance signer approve the hash.
191+
// (store the approval record off-chain keyed by `digest`)
192+
193+
// 3. Build the SignedAttestation.
194+
let network_id = client.env.ledger().network_id(); // or from the RPC node
195+
let attestation = SignedAttestation { network_id, digest };
196+
197+
// 4. Optional: verify before submitting (catches env misconfiguration early).
198+
client.verify_attestation_digest(
199+
&attestation, &issuer, &namespace, &token, &from, &to, &amount_bps,
200+
)?;
201+
202+
// 5. Submit the transfer.
203+
client.transfer_with_attestation(
204+
&issuer, &namespace, &token, &from, &to, &amount_bps, &digest,
205+
);
206+
```
207+
208+
### Security guarantees
209+
210+
| Property | Guarantee |
211+
|----------|-----------|
212+
| Cross-network replay prevention | `network_id` in preimage binds digest to one chain |
213+
| Parameter binding | All six transfer params are in the signed preimage; changing any one invalidates the digest |
214+
| No-aliasing | Different `amount_bps` (or any other param) produce distinct digests |
215+
| Read-only verification | `verify_attestation_digest` writes no state; safe to call speculatively |
216+
| Fail-closed | Any mismatch (network_id or digest) returns `NetworkIdMismatch`; no partial success |
217+
218+
### Error code
219+
220+
| Code | Name | Meaning |
221+
|------|------|---------|
222+
| 62 | `NetworkIdMismatch` | Attestation's `network_id` does not match the current chain, **or** the digest does not match the expected canonical preimage. |
223+
224+
---
225+
76226
## Event
77227

78228
```
@@ -82,6 +232,8 @@ data: (from, to, shares_bps, attest_hash)
82232

83233
Symbol: `xfer_att` (8 chars, fits in `symbol_short!`).
84234

235+
---
236+
85237
## Example
86238

87239
```rust
@@ -92,14 +244,16 @@ client.transfer_with_attestation(
92244
&namespace,
93245
&token,
94246
&alice, // must sign
95-
&bob, // must sign
247+
&bob, // must sign (issuer also signs)
96248
&2_500u32,
97249
&category,
98250
&approval_doc_hash,
99251
&network_id,
100252
);
101253
```
102254

255+
---
256+
103257
## Error Reference
104258

105259
| Error | Meaning |
@@ -112,5 +266,39 @@ client.transfer_with_attestation(
112266
| `HolderBlacklisted` | `from` or `to` is blacklisted |
113267
| `NotAuthorized` | Whitelist active and `from` or `to` not listed |
114268
| `InvalidShareBps` | `shares_bps == 0`, `from` has insufficient shares, or `to` would exceed 10 000 bps |
115-
| `NetworkIdMismatch` | The supplied attestation network id does not match the active ledger network |
269+
| `NetworkIdMismatch` | Attestation `network_id` or digest does not match the current chain |
116270
| `LimitReached` | Arithmetic overflow in `to` share accumulation (edge case) |
271+
272+
---
273+
274+
## Test Coverage
275+
276+
All guards and the network-id domain separator are covered in
277+
`src/test_transfer_with_attestation.rs`:
278+
279+
| Scenario | Test function |
280+
|----------|---------------|
281+
| Global freeze blocks transfer | `transfer_blocked_when_frozen` |
282+
| Global pause blocks transfer | `transfer_blocked_when_paused` |
283+
| Self-transfer rejected | `self_transfer_rejected` |
284+
| Zero-shares rejected | `zero_shares_rejected` |
285+
| Unknown offering rejected | `unknown_offering_rejected` |
286+
| Wrong issuer rejected | `wrong_issuer_rejected` |
287+
| Offering-level freeze | `transfer_blocked_when_offering_frozen` |
288+
| Blacklisted `from` | `blacklisted_from_rejected` |
289+
| Blacklisted `to` | `blacklisted_to_rejected` |
290+
| Whitelist unlisted `from` | `whitelist_unlisted_from_rejected` |
291+
| Whitelist unlisted `to` | `whitelist_unlisted_to_rejected` |
292+
| Both whitelisted succeeds | `whitelist_both_listed_succeeds` |
293+
| Insufficient shares | `insufficient_shares_rejected` |
294+
| Recipient share cap | `recipient_share_cap_rejected` |
295+
| Happy path full transfer | `happy_path_full_transfer` |
296+
| Happy path partial transfer | `happy_path_partial_transfer` |
297+
| `HolderShareTotal` invariant | `share_total_invariant_after_transfer` |
298+
| Event payload correct | `event_payload_correct` |
299+
| Correct network_id + digest | `verify_attestation_correct_network_id` |
300+
| Mainnet id on testnet rejected | `verify_attestation_mainnet_id_on_testnet_rejected` |
301+
| Testnet id on mainnet rejected | `verify_attestation_testnet_id_on_mainnet_rejected` |
302+
| Unknown network_id rejected | `verify_attestation_unknown_network_id_rejected` |
303+
| Wrong digest rejected | `verify_attestation_wrong_digest_rejected` |
304+
| Compute → verify round-trip | `attestation_compute_verify_round_trip` |

0 commit comments

Comments
 (0)