Skip to content

Commit eaa6569

Browse files
committed
fix: idempotent dose logging, timezone-safe schedules, canonical commitments, co-sign replay guard
Closes #958, closes #957, closes #955, closes #953. - medicationService: add stable scheduledDoseId + logDoseIdempotent so a dose marked from a notification action and a replayed offline-queue entry converge on one record (#958). - medicationService: add doseIdentityKey / reconcileDoseSchedules keyed on the absolute UTC instant so schedule edits and timezone/DST changes no longer stack overlapping local notifications (#957). - blockchainIntegration: add versioned, domain-separated canonical serialization (canonicalizeMedicalCommitment / hashMedicalCommitment / verifyMedicalCommitment) with golden vectors shared with the contract suite (#955). - coSignReplayGuard + PendingCoSignScreen: nonce/status/expiry checks and an atomic consume step so captured co-sign links cannot be replayed (#953). - Adds focused unit tests characterizing prior behavior for each change.
1 parent 2224fc2 commit eaa6569

8 files changed

Lines changed: 804 additions & 1 deletion

src/screens/PendingCoSignScreen.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ import {
2121
} from 'react-native';
2222

2323
import multisigService, { type PendingTransactionResponse } from '../services/multisigService';
24+
import {
25+
evaluateCoSignEligibility,
26+
markCoSignConsumed,
27+
releaseCoSignClaim,
28+
CO_SIGN_REJECT_MESSAGE,
29+
} from '../utils/coSignReplayGuard';
2430

2531
// ─── Props ────────────────────────────────────────────────────────────────────
2632

@@ -65,13 +71,20 @@ const PendingCoSignScreen: React.FC<Props> = ({
6571

6672
const userSigner = transaction.signers.find((s) => s.publicKey === currentUserPublicKey);
6773
const alreadySigned = userSigner?.hasSigned ?? false;
68-
const canSign = !!userSigner && !alreadySigned && transaction.status === 'pending';
74+
const eligibility = evaluateCoSignEligibility(transaction, currentUserPublicKey);
75+
const canSign = eligibility.canSign;
6976

7077
const isExpired = new Date() > new Date(transaction.expiresAt);
7178
const isExpiringSoon =
7279
!isExpired && new Date(transaction.expiresAt).getTime() - Date.now() < 24 * 60 * 60 * 1000;
7380

7481
const handleSign = async () => {
82+
// Replay guard: re-check the request is still signable before doing anything.
83+
const gate = evaluateCoSignEligibility(transaction, currentUserPublicKey);
84+
if (!gate.canSign) {
85+
Alert.alert('Cannot Sign', CO_SIGN_REJECT_MESSAGE[gate.reason ?? 'not-pending']);
86+
return;
87+
}
7588
if (!privateKey.trim()) {
7689
Alert.alert(
7790
'Private Key Required',
@@ -96,6 +109,12 @@ const PendingCoSignScreen: React.FC<Props> = ({
96109
text: 'Sign',
97110
style: 'destructive',
98111
onPress: async () => {
112+
// Atomically claim this request+nonce. If another tap / re-entered
113+
// screen already claimed it, bail out instead of double-submitting.
114+
if (!markCoSignConsumed(transaction)) {
115+
Alert.alert('Cannot Sign', CO_SIGN_REJECT_MESSAGE['nonce-consumed']);
116+
return;
117+
}
99118
setSigning(true);
100119
try {
101120
// In production: use the keypair from secure storage, not user input
@@ -110,6 +129,8 @@ const PendingCoSignScreen: React.FC<Props> = ({
110129
[{ text: 'OK', onPress: onSigned }],
111130
);
112131
} catch (error: any) {
132+
// Submission failed — release the claim so a retry is possible.
133+
releaseCoSignClaim(transaction);
113134
Alert.alert('Signing Failed', error?.message ?? 'Failed to sign transaction.');
114135
} finally {
115136
setSigning(false);
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* #955 — on-chain medical commitments must use a deterministic, versioned,
3+
* domain-separated pre-image so the mobile app, backend, and Soroban contract
4+
* tests all derive the same hash.
5+
*
6+
* The golden vectors below are the shared contract: any change to them must be
7+
* mirrored in the contract test-suite and paired with a version bump.
8+
*/
9+
import {
10+
MEDICAL_COMMITMENT_DOMAIN,
11+
MEDICAL_COMMITMENT_VERSION,
12+
canonicalizeMedicalCommitment,
13+
hashMedicalCommitment,
14+
verifyMedicalCommitment,
15+
type MedicalCommitment,
16+
} from '../blockchainIntegration';
17+
18+
const VECTOR_A: MedicalCommitment = {
19+
recordId: 'rec_001',
20+
petId: 'pet_abc',
21+
payloadHash: 'a'.repeat(64),
22+
issuedAt: 1735689600000,
23+
issuer: 'GABC123',
24+
};
25+
26+
const VECTOR_B: MedicalCommitment = {
27+
recordId: 'rec_002',
28+
petId: 'pet_xyz',
29+
payloadHash: 'deadbeef'.repeat(8),
30+
issuedAt: 0,
31+
issuer: 'GXYZ',
32+
};
33+
34+
const GOLDEN = {
35+
a: {
36+
canonical:
37+
'{"domain":"petchain.medical.commitment","fields":{"issuedAt":1735689600000,"issuer":"GABC123","payloadHash":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","petId":"pet_abc","recordId":"rec_001"},"version":1}',
38+
sha256: '87dce2b771b56cdb0c57a67872a882724dfccf57cc24faf80a0cd70c280eb0c3',
39+
},
40+
b: {
41+
canonical:
42+
'{"domain":"petchain.medical.commitment","fields":{"issuedAt":0,"issuer":"GXYZ","payloadHash":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef","petId":"pet_xyz","recordId":"rec_002"},"version":1}',
43+
sha256: '4c0214f74ccdce3287e7f0846d861bf761fd008429cec210fed278845bc91e8f',
44+
},
45+
};
46+
47+
describe('canonicalizeMedicalCommitment — golden vectors', () => {
48+
it('exposes a stable domain tag and version', () => {
49+
expect(MEDICAL_COMMITMENT_DOMAIN).toBe('petchain.medical.commitment');
50+
expect(MEDICAL_COMMITMENT_VERSION).toBe(1);
51+
});
52+
53+
it('matches the golden canonical pre-image', () => {
54+
expect(canonicalizeMedicalCommitment(VECTOR_A)).toBe(GOLDEN.a.canonical);
55+
expect(canonicalizeMedicalCommitment(VECTOR_B)).toBe(GOLDEN.b.canonical);
56+
});
57+
58+
it('matches the golden SHA-256 digest', () => {
59+
expect(hashMedicalCommitment(VECTOR_A)).toBe(GOLDEN.a.sha256);
60+
expect(hashMedicalCommitment(VECTOR_B)).toBe(GOLDEN.b.sha256);
61+
});
62+
});
63+
64+
describe('determinism', () => {
65+
it('is independent of input key order', () => {
66+
const shuffled: MedicalCommitment = {
67+
issuer: VECTOR_A.issuer,
68+
issuedAt: VECTOR_A.issuedAt,
69+
recordId: VECTOR_A.recordId,
70+
payloadHash: VECTOR_A.payloadHash,
71+
petId: VECTOR_A.petId,
72+
};
73+
expect(hashMedicalCommitment(shuffled)).toBe(GOLDEN.a.sha256);
74+
});
75+
76+
it('normalises payloadHash case and unicode form', () => {
77+
expect(
78+
hashMedicalCommitment({ ...VECTOR_A, payloadHash: 'A'.repeat(64) }),
79+
).toBe(GOLDEN.a.sha256);
80+
});
81+
82+
it('changes the digest when any field changes', () => {
83+
expect(hashMedicalCommitment({ ...VECTOR_A, issuedAt: VECTOR_A.issuedAt + 1 })).not.toBe(
84+
GOLDEN.a.sha256,
85+
);
86+
});
87+
});
88+
89+
describe('validation', () => {
90+
it('rejects a malformed payload hash', () => {
91+
expect(() => canonicalizeMedicalCommitment({ ...VECTOR_A, payloadHash: 'xyz' })).toThrow();
92+
});
93+
94+
it('rejects a non-integer / negative issuedAt', () => {
95+
expect(() => canonicalizeMedicalCommitment({ ...VECTOR_A, issuedAt: -1 })).toThrow();
96+
expect(() => canonicalizeMedicalCommitment({ ...VECTOR_A, issuedAt: 1.5 })).toThrow();
97+
});
98+
99+
it('rejects missing identifiers', () => {
100+
expect(() => canonicalizeMedicalCommitment({ ...VECTOR_A, recordId: '' })).toThrow();
101+
});
102+
});
103+
104+
describe('verifyMedicalCommitment', () => {
105+
it('accepts a correct digest (any case)', () => {
106+
expect(verifyMedicalCommitment(VECTOR_A, GOLDEN.a.sha256)).toBe(true);
107+
expect(verifyMedicalCommitment(VECTOR_A, GOLDEN.a.sha256.toUpperCase())).toBe(true);
108+
});
109+
110+
it('rejects a wrong or truncated digest', () => {
111+
expect(verifyMedicalCommitment(VECTOR_A, GOLDEN.b.sha256)).toBe(false);
112+
expect(verifyMedicalCommitment(VECTOR_A, 'abcd')).toBe(false);
113+
});
114+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* #958 — dose logging must be idempotent across offline-queue replay and
3+
* notification actions. Characterises the old duplicate behaviour, then locks
4+
* in the conflict-safe path.
5+
*/
6+
7+
// In-memory stand-in for the encrypted SQLite dose-log store.
8+
const store: any[] = [];
9+
10+
jest.mock('../localDB', () => ({
11+
getAllMedications: jest.fn(async () => []),
12+
upsertMedication: jest.fn(async () => {}),
13+
deleteMedicationById: jest.fn(async () => {}),
14+
getDoseLogs: jest.fn(async () => [...store]),
15+
addDoseLog: jest.fn(async (log: any) => {
16+
const idx = store.findIndex((l) => l.id === log.id);
17+
if (idx >= 0) store[idx] = log;
18+
else store.push(log);
19+
}),
20+
}));
21+
22+
import {
23+
scheduledDoseId,
24+
isDoseAlreadyLogged,
25+
logDose,
26+
logDoseIdempotent,
27+
getDoseLogs,
28+
type DoseLog,
29+
} from '../medicationService';
30+
31+
const SCHEDULED_FOR = '2026-03-01T08:00:00.000Z';
32+
33+
const baseLog = (overrides: Partial<DoseLog> = {}): DoseLog => ({
34+
id: `log-${Math.random().toString(36).slice(2)}`,
35+
medicationId: 'med-1',
36+
takenAt: '2026-03-01T08:03:12.000Z',
37+
scheduledFor: SCHEDULED_FOR,
38+
...overrides,
39+
});
40+
41+
beforeEach(() => {
42+
store.length = 0;
43+
jest.clearAllMocks();
44+
});
45+
46+
describe('scheduledDoseId', () => {
47+
it('is stable for the same medication + scheduled instant', () => {
48+
expect(scheduledDoseId('med-1', SCHEDULED_FOR)).toBe(
49+
scheduledDoseId('med-1', new Date(SCHEDULED_FOR)),
50+
);
51+
});
52+
53+
it('ignores sub-minute clock skew between entry points', () => {
54+
expect(scheduledDoseId('med-1', '2026-03-01T08:00:05.000Z')).toBe(
55+
scheduledDoseId('med-1', '2026-03-01T08:00:59.999Z'),
56+
);
57+
});
58+
59+
it('differs by medication and by dose time', () => {
60+
expect(scheduledDoseId('med-1', SCHEDULED_FOR)).not.toBe(
61+
scheduledDoseId('med-2', SCHEDULED_FOR),
62+
);
63+
expect(scheduledDoseId('med-1', SCHEDULED_FOR)).not.toBe(
64+
scheduledDoseId('med-1', '2026-03-01T20:00:00.000Z'),
65+
);
66+
});
67+
68+
it('throws on an invalid timestamp', () => {
69+
expect(() => scheduledDoseId('med-1', 'not-a-date')).toThrow();
70+
});
71+
});
72+
73+
describe('current behaviour: plain logDose double-counts', () => {
74+
it('writes two records when the same dose is marked from two entry points', async () => {
75+
await logDose(baseLog({ id: 'from-notification' }));
76+
await logDose(baseLog({ id: 'from-offline-replay' }));
77+
expect(await getDoseLogs()).toHaveLength(2);
78+
});
79+
});
80+
81+
describe('logDoseIdempotent', () => {
82+
it('writes the dose once and reports later attempts as duplicates', async () => {
83+
const first = await logDoseIdempotent(baseLog({ id: 'from-notification' }));
84+
expect(first.duplicate).toBe(false);
85+
86+
const replay = await logDoseIdempotent(baseLog({ id: 'from-offline-replay' }));
87+
expect(replay.duplicate).toBe(true);
88+
expect(replay.log.id).toBe('from-notification'); // authoritative record
89+
90+
expect(await getDoseLogs()).toHaveLength(1);
91+
});
92+
93+
it('stamps a stable scheduledDoseId on the stored log', async () => {
94+
const { log } = await logDoseIdempotent(baseLog());
95+
expect(log.scheduledDoseId).toBe(scheduledDoseId('med-1', SCHEDULED_FOR));
96+
});
97+
98+
it('dedupes even when only takenAt is available (no scheduledFor)', async () => {
99+
await logDoseIdempotent(baseLog({ id: 'a', scheduledFor: undefined, takenAt: '2026-03-01T08:00:10.000Z' }));
100+
const dup = await logDoseIdempotent(
101+
baseLog({ id: 'b', scheduledFor: undefined, takenAt: '2026-03-01T08:00:40.000Z' }),
102+
);
103+
expect(dup.duplicate).toBe(true);
104+
expect(await getDoseLogs()).toHaveLength(1);
105+
});
106+
107+
it('still records genuinely different doses', async () => {
108+
await logDoseIdempotent(baseLog({ id: 'morning' }));
109+
const evening = await logDoseIdempotent(
110+
baseLog({ id: 'evening', scheduledFor: '2026-03-01T20:00:00.000Z' }),
111+
);
112+
expect(evening.duplicate).toBe(false);
113+
expect(await getDoseLogs()).toHaveLength(2);
114+
});
115+
});
116+
117+
describe('isDoseAlreadyLogged', () => {
118+
it('matches on scheduled-dose identity across differing log ids', () => {
119+
const existing = [baseLog({ id: 'x', scheduledDoseId: scheduledDoseId('med-1', SCHEDULED_FOR) })];
120+
expect(isDoseAlreadyLogged(baseLog({ id: 'y' }), existing)).toBe(true);
121+
expect(isDoseAlreadyLogged(baseLog({ id: 'z', medicationId: 'med-2' }), existing)).toBe(false);
122+
});
123+
});
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/**
2+
* #957 — editing a schedule or travelling across timezones (including DST
3+
* transitions and overnight doses) must not leave overlapping local
4+
* notification schedules. Reconciliation is by absolute dose identity.
5+
*/
6+
jest.mock('../localDB', () => ({
7+
getAllMedications: jest.fn(async () => []),
8+
upsertMedication: jest.fn(async () => {}),
9+
deleteMedicationById: jest.fn(async () => {}),
10+
getDoseLogs: jest.fn(async () => []),
11+
addDoseLog: jest.fn(async () => {}),
12+
}));
13+
14+
import {
15+
doseIdentityKey,
16+
reconcileDoseSchedules,
17+
type ScheduledDose,
18+
} from '../medicationService';
19+
20+
describe('doseIdentityKey', () => {
21+
it('is identical for the same instant expressed in different timezones', () => {
22+
// 2026-03-08T07:30:00Z === 2026-03-08 02:30 America/New_York (pre-DST)
23+
const asUtc: ScheduledDose = { medicationId: 'm', fireDate: '2026-03-08T07:30:00.000Z' };
24+
const asOffset: ScheduledDose = { medicationId: 'm', fireDate: new Date('2026-03-08T02:30:00.000-05:00') };
25+
expect(doseIdentityKey(asUtc)).toBe(doseIdentityKey(asOffset));
26+
});
27+
28+
it('distinguishes an overnight dose from the next morning dose', () => {
29+
const night: ScheduledDose = { medicationId: 'm', fireDate: '2026-03-08T04:00:00.000Z' };
30+
const morning: ScheduledDose = { medicationId: 'm', fireDate: '2026-03-08T13:00:00.000Z' };
31+
expect(doseIdentityKey(night)).not.toBe(doseIdentityKey(morning));
32+
});
33+
});
34+
35+
describe('reconcileDoseSchedules', () => {
36+
it('cancels exact duplicate notifications for the same instant (post-edit stacking)', () => {
37+
const existing: ScheduledDose[] = [
38+
{ medicationId: 'm', notificationId: 'n1', fireDate: '2026-06-01T09:00:00.000Z' },
39+
{ medicationId: 'm', notificationId: 'n2', fireDate: '2026-06-01T09:00:00.000Z' },
40+
];
41+
const desired: ScheduledDose[] = [{ medicationId: 'm', fireDate: '2026-06-01T09:00:00.000Z' }];
42+
43+
const { toCancel, toSchedule, keep } = reconcileDoseSchedules(existing, desired);
44+
expect(toCancel).toEqual(['n2']);
45+
expect(keep).toEqual(['n1']);
46+
expect(toSchedule).toHaveLength(0);
47+
});
48+
49+
it('keeps DST-equivalent schedules instead of re-creating them on travel', () => {
50+
// Traveller re-opens the app; the app recomputes the same absolute dose
51+
// times but from a new device timezone. Nothing should be cancelled or added.
52+
const existing: ScheduledDose[] = [
53+
{ medicationId: 'm', notificationId: 'n1', fireDate: '2026-03-08T07:00:00.000Z' },
54+
{ medicationId: 'm', notificationId: 'n2', fireDate: '2026-03-08T19:00:00.000Z' },
55+
];
56+
const desired: ScheduledDose[] = [
57+
{ medicationId: 'm', fireDate: new Date('2026-03-08T02:00:00.000-05:00') },
58+
{ medicationId: 'm', fireDate: new Date('2026-03-08T14:00:00.000-05:00') },
59+
];
60+
61+
const { toCancel, toSchedule, keep } = reconcileDoseSchedules(existing, desired);
62+
expect(toCancel).toHaveLength(0);
63+
expect(toSchedule).toHaveLength(0);
64+
expect(keep.sort()).toEqual(['n1', 'n2']);
65+
});
66+
67+
it('cancels stale doses and schedules new ones after a schedule change', () => {
68+
const existing: ScheduledDose[] = [
69+
{ medicationId: 'm', notificationId: 'old', fireDate: '2026-06-01T08:00:00.000Z' },
70+
];
71+
const desired: ScheduledDose[] = [{ medicationId: 'm', fireDate: '2026-06-01T12:00:00.000Z' }];
72+
73+
const { toCancel, toSchedule } = reconcileDoseSchedules(existing, desired);
74+
expect(toCancel).toEqual(['old']);
75+
expect(toSchedule).toEqual([{ medicationId: 'm', fireDate: '2026-06-01T12:00:00.000Z' }]);
76+
});
77+
78+
it('is a no-op for an already-consistent overnight schedule', () => {
79+
const existing: ScheduledDose[] = [
80+
{ medicationId: 'm', notificationId: 'n1', fireDate: '2026-11-01T05:30:00.000Z' },
81+
];
82+
const desired: ScheduledDose[] = [{ medicationId: 'm', fireDate: '2026-11-01T05:30:00.000Z' }];
83+
const result = reconcileDoseSchedules(existing, desired);
84+
expect(result).toEqual({ toCancel: [], toSchedule: [], keep: ['n1'] });
85+
});
86+
});

0 commit comments

Comments
 (0)