@@ -9,23 +9,26 @@ holders of a revenue-share offering. Both parties must co-sign the transaction,
99This makes on-chain share handoffs a first-class operation while preserving the issuer's
1010ability to enforce jurisdiction restrictions through blacklists and whitelists.
1111
12+ ---
13+
1214## Signature
1315
1416``` rust
1517pub 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
3134Ten 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
5255blacklisted address is always excluded regardless of whitelist membership .
5356
57+ ---
58+
5459## Storage Invariant
5560
5661A 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.
6065This ensures that subsequent calls to `set_holder_share ` see the correct running total
6166and correctly enforce the per - offering 10 000 bps cap .
6267
68+ ---
69+
6370## Attestation Hash
6471
6572The 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
7380document (KYC confirmation , AML clearance , jurisdiction sign - off , etc . ) so that the
7481on - 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
83233Symbol: ` 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