cratefield-module-wallets 0.1.3

Cratefield module: link EVM and Solana wallets to a signed-in account via SIWE/SIWS signature verification
Documentation
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
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
//! What the module refuses (issue #835): replay, a message bound to another
//! domain, an expired one, a wallet this account does not own, and — the
//! one that needs a chain but never reaches one — a smart-contract wallet
//! answered by an EIP-1271 verifier.
//!
//! The contract-wallet case runs entirely offline through
//! `StaticContractVerifier`, the no-network adapter the crate ships for it,
//! so the EIP-1271 path is proved without an RPC endpoint.

mod support;

use std::sync::Arc;

use axum::http::StatusCode;
use cratefield_module_wallets::{ContractSignatureVerifier, StaticContractVerifier};
use cratefield_testing::TestHarness;
use serde_json::json;
use time::Duration;

use support::{
    ALICE, BOB, EvmWallet, RecordingVerifier, SolanaWallet, Spec, advance, eip191_hash, fixture,
    kit, links, list, nonce, now, raw_keccak, test_clock, unlink, verify, verify_body,
    verify_concurrently,
};

/// The EIP-1271 magic return value a contract ends its `isValidSignature`
/// answer with: `0x1626ba7e`. A signature that starts with it is a contract
/// wallet's, never an EOA's, and is what falls through to the verifier.
const EIP_1271_MAGIC: &str = "1626ba7e";

/// A contract address that is a well-formed EVM address but has no key
/// behind it — a Safe, a Gnosis multisig, a smart account.
const CONTRACT: &str = "0x1a642f0E3c3aF545E7AcBD38b07251B3990914F1";

// ---------------------------------------------------------------------------
// Replay

#[pollster::test]
async fn a_nonce_cannot_be_used_twice() {
    let (kit, _clock) = fixture();
    let wallet = EvmWallet::from_seed(1);

    let fields = nonce(&kit, ALICE, "evm").await;
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);
    let body = verify_body("evm", &message, &signature);

    let first = verify(&kit, ALICE, &body).await;
    assert_eq!(first.status, StatusCode::OK, "{}", first.text());

    // The exact same message and signature, the second time.
    let replay = verify(&kit, ALICE, &body).await;
    assert_eq!(replay.status, StatusCode::BAD_REQUEST, "{}", replay.text());
    assert!(
        replay.problem().ends_with("wallets/nonce-invalid"),
        "{}",
        replay.problem()
    );

    // And the replay changed nothing: still one link, not two.
    assert_eq!(links(&list(&kit, ALICE).await.json()).len(), 1);
}

#[pollster::test]
async fn a_nonce_spent_by_two_concurrent_verifies_is_spent_once() {
    let (kit, _clock) = fixture();
    let wallet = EvmWallet::from_seed(1);

    // The nonce consume is a guarded `UPDATE … WHERE consumed_at IS NULL`,
    // so the row count is what decides the winner. One-at-a-time replay is
    // covered above; this fires both requests together, because that is the
    // case the guard exists for.
    let fields = nonce(&kit, ALICE, "evm").await;
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);
    let body = verify_body("evm", &message, &signature);

    let responses = verify_concurrently(&kit, ALICE, &body, 2).await;

    let winners: Vec<_> = responses
        .iter()
        .filter(|res| res.status == StatusCode::OK)
        .collect();
    let losers: Vec<_> = responses
        .iter()
        .filter(|res| res.status != StatusCode::OK)
        .collect();
    assert_eq!(
        winners.len(),
        1,
        "exactly one verify wins the nonce: {:?}",
        responses.iter().map(|r| r.status).collect::<Vec<_>>()
    );
    assert_eq!(losers.len(), 1);
    for loser in &losers {
        assert_eq!(loser.status, StatusCode::BAD_REQUEST, "{}", loser.text());
        assert!(
            loser.problem().ends_with("wallets/nonce-invalid"),
            "the loser is told the nonce is gone: {}",
            loser.problem()
        );
    }

    // And the race produced one link, not two.
    let linked = links(&list(&kit, ALICE).await.json());
    assert_eq!(linked.len(), 1, "{linked:?}");
    assert_eq!(
        linked[0].2.to_ascii_lowercase(),
        wallet.address().to_ascii_lowercase()
    );
}

#[pollster::test]
async fn a_nonce_that_never_reached_its_owner_is_refused() {
    let (kit, _clock) = fixture();
    let wallet = EvmWallet::from_seed(1);

    // Alice's nonce, spent by Bob: the signature is perfect, the session is
    // the wrong one.
    let fields = nonce(&kit, ALICE, "evm").await;
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);

    let response = verify(&kit, BOB, &verify_body("evm", &message, &signature)).await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/nonce-invalid"),
        "{}",
        response.problem()
    );
    // The nonce survives: a refused attempt does not burn it, so Alice can
    // still use her own.
    assert!(
        verify(&kit, ALICE, &verify_body("evm", &message, &signature))
            .await
            .status
            .is_success()
    );
}

#[pollster::test]
async fn a_nonce_the_server_never_minted_is_refused() {
    let (kit, _clock) = fixture();
    let wallet = EvmWallet::from_seed(1);

    let mut fields = nonce(&kit, ALICE, "evm").await;
    fields.nonce = "NOTAMINTEDNONCE".to_owned();
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);

    let response = verify(&kit, ALICE, &verify_body("evm", &message, &signature)).await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/nonce-invalid"),
        "{}",
        response.problem()
    );
}

#[pollster::test]
async fn a_nonce_minted_for_the_other_chain_is_refused() {
    let (kit, _clock) = fixture();
    let wallet = EvmWallet::from_seed(1);

    // The message names the right chain id for an EVM verify, so the chain-id
    // check passes and what refuses it is the nonce row's own `chain`.
    let fields = nonce(&kit, ALICE, "solana").await.with_chain_id("1");
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);

    let response = verify(&kit, ALICE, &verify_body("evm", &message, &signature)).await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/nonce-invalid"),
        "the nonce row carries its chain: {}",
        response.problem()
    );
}

#[pollster::test]
async fn a_lapsed_nonce_is_refused() {
    let clock = test_clock();
    let kit = kit(
        &Spec {
            nonce_ttl: Some(Duration::seconds(60)),
            ..Spec::new()
        },
        &clock,
    );
    let wallet = EvmWallet::from_seed(1);

    // The message outlives the nonce, so the nonce is what refuses it and
    // the refusal is about the nonce rather than about the message.
    let fields = nonce(&kit, ALICE, "evm")
        .await
        .expired_at(now(&clock) + Duration::hours(1));
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);

    advance(&clock, 61);

    let response = verify(&kit, ALICE, &verify_body("evm", &message, &signature)).await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/nonce-invalid"),
        "{}",
        response.problem()
    );
    assert!(links(&list(&kit, ALICE).await.json()).is_empty());
}

// ---------------------------------------------------------------------------
// Domain binding

#[pollster::test]
async fn a_message_bound_to_another_domain_is_refused() {
    let (kit, _clock) = fixture();
    let wallet = EvmWallet::from_seed(1);

    // A real nonce, a real signature — over a message that says this
    // wallet.test is asking when it is somebody else. This is the phishing
    // shape, and it must not verify here.
    let fields = nonce(&kit, ALICE, "evm").await.with_domain("evil.test");
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);

    let response = verify(&kit, ALICE, &verify_body("evm", &message, &signature)).await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/domain-mismatch"),
        "{}",
        response.problem()
    );
    assert!(links(&list(&kit, ALICE).await.json()).is_empty());
}

#[pollster::test]
async fn a_solana_message_bound_to_another_domain_is_refused() {
    let (kit, _clock) = fixture();
    let wallet = SolanaWallet::from_seed(7);

    let fields = nonce(&kit, ALICE, "solana").await.with_domain("evil.test");
    let message = fields.siws(wallet.address());
    let signature = wallet.sign(&message);

    let response = verify(&kit, ALICE, &verify_body("solana", &message, &signature)).await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/domain-mismatch"),
        "{}",
        response.problem()
    );
}

// ---------------------------------------------------------------------------
// Expiry

#[pollster::test]
async fn an_expired_message_is_refused() {
    let clock = test_clock();
    let kit = kit(&Spec::new(), &clock);
    let wallet = EvmWallet::from_seed(1);

    let fields = nonce(&kit, ALICE, "evm").await;
    // The nonce is fresh, the signature is good, the message has expired.
    let fields = fields.expired_at(now(&clock) - Duration::hours(1));
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);

    let response = verify(&kit, ALICE, &verify_body("evm", &message, &signature)).await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/message-expired"),
        "{}",
        response.problem()
    );
    assert!(links(&list(&kit, ALICE).await.json()).is_empty());
}

#[pollster::test]
async fn a_message_that_is_not_valid_yet_is_refused() {
    let clock = test_clock();
    let kit = kit(&Spec::new(), &clock);
    let wallet = EvmWallet::from_seed(1);

    let fields = nonce(&kit, ALICE, "evm")
        .await
        .valid_from(now(&clock) + Duration::hours(1));
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);

    let response = verify(&kit, ALICE, &verify_body("evm", &message, &signature)).await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/message-expired"),
        "{}",
        response.problem()
    );
}

#[pollster::test]
async fn a_message_that_expires_while_the_person_reads_is_refused() {
    let clock = test_clock();
    let kit = kit(
        &Spec {
            nonce_ttl: Some(Duration::seconds(600)),
            ..Spec::new()
        },
        &clock,
    );
    let wallet = EvmWallet::from_seed(1);

    let fields = nonce(&kit, ALICE, "evm").await;
    // The message asks to be valid for half a minute and the nonce for ten
    // minutes, so it is the message's own `Expiration Time` that refuses
    // it — not the nonce, which is still good.
    let fields = fields.expired_at(now(&clock) + Duration::seconds(30));
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);

    advance(&clock, 31);

    let response = verify(&kit, ALICE, &verify_body("evm", &message, &signature)).await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/message-expired"),
        "{}",
        response.problem()
    );
    assert!(links(&list(&kit, ALICE).await.json()).is_empty());
}

// ---------------------------------------------------------------------------
// Contract wallets (EIP-1271)

#[pollster::test]
async fn a_contract_wallet_is_accepted_through_the_eip1271_verifier() {
    let clock = test_clock();
    let kit = kit(
        &Spec {
            contract_verifier: Some(Arc::new(StaticContractVerifier::approving(
                CONTRACT.to_owned(),
            ))),
            ..Spec::new()
        },
        &clock,
    );

    let fields = nonce(&kit, ALICE, "evm").await;
    let message = fields.siwe(CONTRACT);
    // A contract wallet's answer: the EIP-1271 magic value, never a 65-byte
    // `r || s || v` an EOA could produce.
    let signature = contract_signature();

    let response = verify(&kit, ALICE, &verify_body("evm", &message, &signature)).await;
    assert_eq!(response.status, StatusCode::OK, "{}", response.text());
    let body = response.json();
    assert_eq!(body["chain"], json!("evm"));
    assert_eq!(
        body["address"]
            .as_str()
            .expect("address")
            .to_ascii_lowercase(),
        CONTRACT.to_ascii_lowercase(),
    );
}

#[pollster::test]
async fn a_contract_wallet_the_verifier_does_not_recognise_is_refused() {
    let clock = test_clock();
    // The verifier approves one address; this kit's contract is another.
    let other = "0xde709f2102306220921060314715629080e2fb77";
    let kit = kit(
        &Spec {
            contract_verifier: Some(Arc::new(StaticContractVerifier::approving(
                other.to_owned(),
            ))),
            ..Spec::new()
        },
        &clock,
    );

    let fields = nonce(&kit, ALICE, "evm").await;
    let message = fields.siwe(CONTRACT);
    let response = verify(
        &kit,
        ALICE,
        &verify_body("evm", &message, &contract_signature()),
    )
    .await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/signature-invalid"),
        "{}",
        response.problem()
    );
    assert!(links(&list(&kit, ALICE).await.json()).is_empty());
}

#[pollster::test]
async fn without_a_verifier_a_contract_wallet_is_refused_rather_than_hanging() {
    let (kit, _clock) = fixture();

    let fields = nonce(&kit, ALICE, "evm").await;
    let message = fields.siwe(CONTRACT);
    let response = verify(
        &kit,
        ALICE,
        &verify_body("evm", &message, &contract_signature()),
    )
    .await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/signature-invalid"),
        "an EOA-only deployment says so rather than calling a chain: {}",
        response.problem()
    );
}

// ---------------------------------------------------------------------------
// Chain-id binding
//
// The `chain` field only names a family ("evm"), so on its own it does not
// say which chain a message was written for. A message minted for one chain
// and presented to another is the same phishing shape as a message minted for
// another domain, and it matters most on the EIP-1271 path, where the chain id
// decides which chain the verifier is asked about.

#[pollster::test]
async fn an_evm_message_for_another_chain_id_is_refused() {
    let clock = test_clock();
    let kit = kit(
        &Spec {
            chain_id: "1".to_owned(),
            ..Spec::new()
        },
        &clock,
    );
    let wallet = EvmWallet::from_seed(1);

    // A real nonce minted here, a real signature over the message — a message
    // that says it is for chain 1337 while this service is chain 1.
    let fields = nonce(&kit, ALICE, "evm").await.with_chain_id("1337");
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);

    let response = verify(&kit, ALICE, &verify_body("evm", &message, &signature)).await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/chain-mismatch"),
        "a cross-chain message is refused: {}",
        response.problem()
    );
    assert!(links(&list(&kit, ALICE).await.json()).is_empty());
}

#[pollster::test]
async fn a_solana_message_for_another_cluster_is_refused() {
    let (kit, _clock) = fixture();
    let wallet = SolanaWallet::from_seed(7);

    // This service mints `mainnet-beta`; a message naming `devnet` is for a
    // different chain and is refused even though the signature is perfect.
    let fields = nonce(&kit, ALICE, "solana").await.with_chain_id("devnet");
    let message = fields.siws(wallet.address());
    let signature = wallet.sign(&message);

    let response = verify(&kit, ALICE, &verify_body("solana", &message, &signature)).await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/chain-mismatch"),
        "{}",
        response.problem()
    );
    assert!(links(&list(&kit, ALICE).await.json()).is_empty());
}

#[pollster::test]
async fn a_non_mainnet_configuration_accepts_only_its_own_chain_id() {
    let clock = test_clock();
    let kit = kit(
        &Spec {
            chain_id: "8453".to_owned(),
            ..Spec::new()
        },
        &clock,
    );
    let wallet = EvmWallet::from_seed(1);

    // The configured chain is accepted...
    let fields = nonce(&kit, ALICE, "evm").await.with_chain_id("8453");
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);
    let response = verify(&kit, ALICE, &verify_body("evm", &message, &signature)).await;
    assert_eq!(response.status, StatusCode::OK, "{}", response.text());

    // ...and mainnet, which the same code would have accepted before the
    // chain id was checked at all, is not.
    let fields = nonce(&kit, BOB, "evm").await.with_chain_id("1");
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);
    let response = verify(&kit, BOB, &verify_body("evm", &message, &signature)).await;
    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/chain-mismatch"),
        "{}",
        response.problem()
    );
}

#[pollster::test]
async fn a_cross_chain_message_never_reaches_the_contract_verifier() {
    let clock = test_clock();
    let verifier = RecordingVerifier::new(CONTRACT);
    let kit = kit(
        &Spec {
            chain_id: "1".to_owned(),
            contract_verifier: Some(Arc::new(verifier.clone())),
            ..Spec::new()
        },
        &clock,
    );

    let fields = nonce(&kit, ALICE, "evm").await.with_chain_id("10");
    let message = fields.siwe(CONTRACT);
    let response = verify(
        &kit,
        ALICE,
        &verify_body("evm", &message, &contract_signature()),
    )
    .await;

    assert_eq!(
        response.status,
        StatusCode::BAD_REQUEST,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/chain-mismatch"),
        "{}",
        response.problem()
    );
    assert!(
        verifier.calls().is_empty(),
        "the verifier is never asked about a chain the message chose: {:?}",
        verifier.calls()
    );
}

#[pollster::test]
async fn the_contract_verifier_is_asked_about_this_services_chain_and_no_other() {
    let clock = test_clock();
    let verifier = RecordingVerifier::new(CONTRACT);
    let kit = kit(
        &Spec {
            chain_id: "8453".to_owned(),
            contract_verifier: Some(Arc::new(verifier.clone())),
            ..Spec::new()
        },
        &clock,
    );

    let fields = nonce(&kit, ALICE, "evm").await.with_chain_id("8453");
    let message = fields.siwe(CONTRACT);
    let response = verify(
        &kit,
        ALICE,
        &verify_body("evm", &message, &contract_signature()),
    )
    .await;
    assert_eq!(response.status, StatusCode::OK, "{}", response.text());

    // An `eth_call` verifier turns this string into an RPC target, so it must
    // be the server's chain, never the message's.
    let call = verifier.only_call();
    assert_eq!(call.chain_id, "8453");
    assert_eq!(
        call.address.to_ascii_lowercase(),
        CONTRACT.to_ascii_lowercase()
    );
}

#[pollster::test]
async fn the_contract_verifier_is_asked_about_the_eip191_hash_of_the_message() {
    let clock = test_clock();
    let verifier = RecordingVerifier::new(CONTRACT);
    let kit = kit(
        &Spec {
            contract_verifier: Some(Arc::new(verifier.clone())),
            ..Spec::new()
        },
        &clock,
    );

    let fields = nonce(&kit, ALICE, "evm").await;
    let message = fields.siwe(CONTRACT);
    let signature = contract_signature();
    let response = verify(&kit, ALICE, &verify_body("evm", &message, &signature)).await;
    assert_eq!(response.status, StatusCode::OK, "{}", response.text());

    let call = verifier.only_call();
    assert_eq!(
        call.message_hash,
        eip191_hash(&message),
        "the EIP-1271 call carries the personal-sign hash of the exact bytes signed"
    );
    assert_eq!(
        call.signature,
        hex::decode(&signature).expect("hex signature")
    );
}

#[test]
fn the_shipped_static_verifier_is_pinned_to_one_message_hash() {
    // The route-level hash binding is proved above through the recording
    // double. This covers the adapter the crate actually ships:
    // `StaticContractVerifier::new` must pin the EIP-191 hash, so feeding it
    // a raw keccak of the same bytes would not pass.
    let pinned = StaticContractVerifier::new(CONTRACT.to_owned(), "the message");
    assert_eq!(
        pollster::block_on(pinned.is_valid_signature(
            "1",
            CONTRACT,
            eip191_hash("the message"),
            &[]
        )),
        Ok(true),
        "the hash it was built for is accepted"
    );
    assert_eq!(
        pollster::block_on(pinned.is_valid_signature(
            "1",
            CONTRACT,
            raw_keccak("the message"),
            &[]
        )),
        Ok(false),
        "a raw keccak of the same bytes is not the personal-sign hash"
    );
    assert_eq!(
        pollster::block_on(pinned.is_valid_signature(
            "1",
            CONTRACT,
            eip191_hash("another message"),
            &[]
        )),
        Ok(false),
        "and so is another message's hash"
    );
}

// ---------------------------------------------------------------------------
// One wallet, one account

#[pollster::test]
async fn a_wallet_links_to_the_person_who_proved_it_and_to_nobody_else() {
    let (kit, _clock) = fixture();
    let wallet = EvmWallet::from_seed(1);

    let id = link_evm(&kit, ALICE, &wallet).await;

    // Alice sees it.
    let mine = links(&list(&kit, ALICE).await.json());
    assert_eq!(mine.len(), 1, "{mine:?}");
    assert_eq!(mine[0].0, id);

    // Bob sees nothing — not the wallet, not the address, not the row.
    let theirs = list(&kit, BOB).await;
    assert_eq!(theirs.status, StatusCode::OK);
    assert!(
        links(&theirs.json()).is_empty(),
        "one account's wallets are not another's: {}",
        theirs.text()
    );
}

#[pollster::test]
async fn bob_cannot_take_over_a_wallet_alice_already_linked() {
    let (kit, _clock) = fixture();
    let wallet = EvmWallet::from_seed(1);
    link_evm(&kit, ALICE, &wallet).await;

    // Bob does hold the key and does produce a valid signature over a fresh
    // nonce of his own. What he has not got is Alice's link — and a
    // signature proves ownership of an address, never of an account's row.
    let fields = nonce(&kit, BOB, "evm").await;
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);

    let response = verify(&kit, BOB, &verify_body("evm", &message, &signature)).await;
    assert_eq!(response.status, StatusCode::CONFLICT, "{}", response.text());
    assert!(
        response
            .problem()
            .ends_with("wallets/address-already-linked"),
        "{}",
        response.problem()
    );

    // Alice's row is untouched, and Bob still has nothing.
    assert_eq!(links(&list(&kit, ALICE).await.json()).len(), 1);
    assert!(links(&list(&kit, BOB).await.json()).is_empty());
}

#[pollster::test]
async fn bob_cannot_unlink_alices_wallet() {
    let (kit, _clock) = fixture();
    let wallet = EvmWallet::from_seed(1);
    let id = link_evm(&kit, ALICE, &wallet).await;

    let response = unlink(&kit, BOB, &id).await;
    assert_eq!(
        response.status,
        StatusCode::NOT_FOUND,
        "{}",
        response.text()
    );
    assert!(
        response.problem().ends_with("wallets/not-found"),
        "{}",
        response.problem()
    );

    // Alice's wallet is still there.
    assert_eq!(links(&list(&kit, ALICE).await.json()).len(), 1);
}

#[pollster::test]
async fn a_wallet_survives_being_linked_again_by_the_same_account() {
    let (kit, _clock) = fixture();
    let wallet = EvmWallet::from_seed(1);
    let first = link_evm(&kit, ALICE, &wallet).await;
    let second = link_evm(&kit, ALICE, &wallet).await;
    assert_eq!(first, second);
    assert_eq!(links(&list(&kit, ALICE).await.json()).len(), 1);
}

// ---------------------------------------------------------------------------
// Helpers

/// A contract wallet's `isValidSignature` return value, hex with no
/// prefix: the four magic bytes plus a payload, which is enough to be
/// unambiguously not a 65-byte EOA signature.
fn contract_signature() -> String {
    let mut signature = String::from(EIP_1271_MAGIC);
    for _ in 0..4 {
        signature.push_str("deadbeef");
    }
    signature
}

/// Links `wallet` for `subject` and returns the new link's id.
async fn link_evm(kit: &TestHarness, subject: &str, wallet: &EvmWallet) -> String {
    let fields = nonce(kit, subject, "evm").await;
    let message = fields.siwe(wallet.address());
    let signature = wallet.sign_personal(&message);
    let response = verify(kit, subject, &verify_body("evm", &message, &signature)).await;
    assert_eq!(response.status, StatusCode::OK, "{}", response.text());
    response.json()["id"].as_str().expect("id").to_owned()
}