diff --git a/meridian-web/components/emergency-transfer/EmergencyTransferDialog.tsx b/meridian-web/components/emergency-transfer/EmergencyTransferDialog.tsx index 84eb1de4..e6d86937 100644 --- a/meridian-web/components/emergency-transfer/EmergencyTransferDialog.tsx +++ b/meridian-web/components/emergency-transfer/EmergencyTransferDialog.tsx @@ -131,6 +131,33 @@ function BoundConfirmationPanel({ {payload.asset.contractAddress || 'Native'} + {payload.quoteId && ( + <> +
Quote ID
+
+ {payload.quoteId} +
+ + )} + + {payload.requestKey && ( + <> +
Request Key
+
+ {payload.requestKey} +
+ + )} + + {payload.nonce && ( + <> +
Nonce
+
+ {payload.nonce} +
+ + )} + {payload.memo && ( <>
Memo
@@ -146,6 +173,7 @@ function BoundConfirmationPanel({ + + {config.quoteId && ( + <> + + } + label="Authorized Quote" + value={config.quoteId} + mono + data-testid="et-detail-quote" + /> + + )} + {config.requestKey && ( + <> + + } + label="Request Key" + value={config.requestKey} + mono + data-testid="et-detail-request-key" + /> + + )} + {config.nonce && ( + <> + + } + label="Nonce" + value={config.nonce} + mono + data-testid="et-detail-nonce" + /> + + )} {config.memo && ( <> @@ -223,6 +259,7 @@ export function EmergencyTransferReviewPanel({ )} + {/* Authorisation display */} diff --git a/meridian-web/hooks/__tests__/useEmergencyTransfer.test.ts b/meridian-web/hooks/__tests__/useEmergencyTransfer.test.ts index 101120ad..78d4f6a7 100644 --- a/meridian-web/hooks/__tests__/useEmergencyTransfer.test.ts +++ b/meridian-web/hooks/__tests__/useEmergencyTransfer.test.ts @@ -26,13 +26,12 @@ import type { ConfirmationPayload } from '@/lib/validations/emergency-transfer' // Helpers // --------------------------------------------------------------------------- -const FUTURE = Date.now() + 10 * 60 * 1000 // 10 min from now - function makeConfig( overrides: Partial> = {}, ): EmergencyTransferConfig { + const defaultExpiresAt = Date.now() + 10 * 60 * 1000 return createEmergencyTransferConfig({ - expiresAt: FUTURE, + expiresAt: defaultExpiresAt, recipient: '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF', amountRaw: '1000000000000000000', amountDisplay: '1.0', @@ -47,6 +46,7 @@ function makeConfig( }) } + const successProvider = vi.fn(async (_p: ConfirmationPayload) => ({ txHash: '0xabc123', })) @@ -55,24 +55,32 @@ const rejectProvider = vi.fn(async (_p: ConfirmationPayload): Promise<{ txHash: throw Object.assign(new Error('Insufficient funds'), { code: 'INSUFFICIENT_FUNDS' }) }) +interface SetupProps { + cfg?: EmergencyTransferConfig | null + prov?: typeof successProvider + now?: () => number +} + function setup( config: EmergencyTransferConfig | null, provider = successProvider, getNow?: () => number, ) { return renderHook( - ({ cfg, prov, now }: { - cfg: EmergencyTransferConfig | null - prov: typeof successProvider - now?: () => number - }) => - useEmergencyTransfer({ config: cfg, provider: prov, getNow: now }), + (props: SetupProps) => + useEmergencyTransfer({ + config: props.cfg ?? null, + provider: props.prov ?? successProvider, + getNow: props.now, + }), { initialProps: { cfg: config, prov: provider, now: getNow }, }, ) } + + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -228,11 +236,10 @@ describe('useEmergencyTransfer', () => { const drifted = makeConfig({ recipient: '0x1111111111111111111111111111111111111111', - expiresAt: FUTURE, }) // Simulate parent updating the config prop - rerender({ cfg: drifted, prov: successProvider }) + rerender({ cfg: drifted, prov: successProvider, now: undefined }) // Allow effects to flush await act(async () => { await Promise.resolve() }) @@ -249,7 +256,7 @@ describe('useEmergencyTransfer', () => { act(() => { result.current.bindConfirmation() }) const drifted = makeConfig({ amountRaw: '2000000000000000000' }) - rerender({ cfg: drifted, prov: successProvider }) + rerender({ cfg: drifted, prov: successProvider, now: undefined }) await act(async () => { await Promise.resolve() }) expect(result.current.state.phase).toBe('config_changed') @@ -263,7 +270,7 @@ describe('useEmergencyTransfer', () => { act(() => { result.current.bindConfirmation() }) const drifted = makeConfig({ networkId: 'polygon' }) - rerender({ cfg: drifted, prov: successProvider }) + rerender({ cfg: drifted, prov: successProvider, now: undefined }) await act(async () => { await Promise.resolve() }) expect(result.current.state.phase).toBe('config_changed') @@ -278,7 +285,8 @@ describe('useEmergencyTransfer', () => { // Drift the config prop but skip the effect by not awaiting const drifted = makeConfig({ recipient: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }) - rerender({ cfg: drifted, prov: successProvider }) + rerender({ cfg: drifted, prov: successProvider, now: undefined }) + // Submit before the effect fires await act(async () => { await result.current.submit() }) @@ -453,10 +461,15 @@ describe('useEmergencyTransfer', () => { act(() => { result.current.bindConfirmation() }) // Fire first submit (pending) - const firstSubmit = act(async () => { await result.current.submit() }) + let p1!: Promise + act(() => { + p1 = result.current.submit() + }) // Fire second submit while first is in flight - await act(async () => { await result.current.submit() }) + act(() => { + result.current.submit() + }) // DUPLICATE_BLOCKED event must have been emitted expect( @@ -466,9 +479,10 @@ describe('useEmergencyTransfer', () => { // Resolve first submit await act(async () => { resolveFirst({ txHash: '0xabc' }) - await firstSubmit + await p1 }) }) + }) // ------------------------------------------------------------------------- @@ -1149,3 +1163,4 @@ describe('useEmergencyTransfer', () => { }) }) }) + diff --git a/meridian-web/hooks/useEmergencyTransfer.ts b/meridian-web/hooks/useEmergencyTransfer.ts index 09e50511..3e379a2d 100644 --- a/meridian-web/hooks/useEmergencyTransfer.ts +++ b/meridian-web/hooks/useEmergencyTransfer.ts @@ -41,6 +41,19 @@ import { createEvent, deriveBindingKey, type EmergencyTransferEvent, + type ReviewStartedEvent, + type RiskAcknowledgedEvent, + type RiskUnacknowledgedEvent, + type ConfirmationBoundEvent, + type SubmitAttemptedEvent, + type SubmitSucceededEvent, + type SubmitFailedEvent, + type DuplicateBlockedEvent, + type ConflictingKeyReusedEvent, + type ExpiredEvent, + type ConfigChangedEvent, + type UnauthorizedEvent, + type DismissedEvent, } from '@/models/emergency-transfer-event' import { @@ -92,6 +105,7 @@ type Action = | { type: 'SUBMIT_SUCCESS'; txHash: string } | { type: 'SUBMIT_FAILURE'; errorCode: string; errorMessage: string } | { type: 'DUPLICATE_BLOCKED' } + | { type: 'CONFLICTING_KEY_REUSED'; reason: string } | { type: 'EXPIRE' } | { type: 'CONFIG_CHANGED'; newConfig: EmergencyTransferConfig } | { type: 'UNAUTHORIZED'; reason: string } @@ -161,7 +175,7 @@ function reducer( switch (action.type) { case 'START_REVIEW': { - const event = createEvent({ + const event = createEvent({ eventType: 'REVIEW_STARTED', configSnapshot: action.config, }) @@ -178,7 +192,7 @@ function reducer( case 'ACKNOWLEDGE_RISK': { if (state.phase !== 'reviewing') return state - const event = createEvent({ + const event = createEvent({ eventType: 'RISK_ACKNOWLEDGED', configSnapshot: state.reviewedConfig!, acknowledgedText: RISK_ACKNOWLEDGEMENT_TEXT, @@ -191,7 +205,7 @@ function reducer( case 'UNACKNOWLEDGE_RISK': { if (state.phase !== 'reviewing') return state - const event = createEvent({ + const event = createEvent({ eventType: 'RISK_UNACKNOWLEDGED', configSnapshot: state.reviewedConfig!, }) @@ -203,7 +217,7 @@ function reducer( case 'BIND_CONFIRMATION': { if (state.phase !== 'reviewing' || !state.riskAcknowledged) return state - const event = createEvent({ + const event = createEvent({ eventType: 'CONFIRMATION_BOUND', configSnapshot: state.reviewedConfig!, bindingKey: action.bindingKey, @@ -216,7 +230,7 @@ function reducer( case 'SUBMIT': { if (state.phase !== 'confirmed') return state - const event = createEvent({ + const event = createEvent({ eventType: 'SUBMIT_ATTEMPTED', configSnapshot: state.reviewedConfig!, bindingKey: state.bindingKey!, @@ -225,8 +239,8 @@ function reducer( } case 'SUBMIT_SUCCESS': { - if (state.phase !== 'submitting') return state - const event = createEvent({ + if (state.phase !== 'submitting' && state.phase !== 'confirmed') return state + const event = createEvent({ eventType: 'SUBMIT_SUCCEEDED', configSnapshot: state.reviewedConfig!, txHash: action.txHash, @@ -240,7 +254,7 @@ function reducer( case 'SUBMIT_FAILURE': { if (state.phase !== 'submitting') return state - const event = createEvent({ + const event = createEvent({ eventType: 'SUBMIT_FAILED', configSnapshot: state.reviewedConfig!, errorCode: action.errorCode, @@ -270,10 +284,12 @@ function reducer( state.phase !== 'confirmed' && state.phase !== 'submitting' ) - return state - const event = createEvent({ + } + + case 'EXPIRE': { + const event = createEvent({ eventType: 'EXPIRED', - configSnapshot: state.reviewedConfig!, + configSnapshot: state.reviewedConfig ?? ({} as EmergencyTransferConfig), }) return appendEvent( { @@ -286,6 +302,7 @@ function reducer( ) } + case 'CONFIG_CHANGED': { if ( state.phase !== 'reviewing' && @@ -334,7 +351,7 @@ function reducer( case 'DISMISS': { if (state.reviewedConfig) { - const event = createEvent({ + const event = createEvent({ eventType: 'DISMISSED', configSnapshot: state.reviewedConfig, }) @@ -343,6 +360,7 @@ function reducer( return initialState } + case 'RESET': return { ...initialState } @@ -439,8 +457,10 @@ export function useEmergencyTransfer({ /** Guards against concurrent submits. */ const submittingRef = useRef(false) - /** Tracks whether a submit has already succeeded for this binding key. */ - const succeededKeysRef = useRef>(new Set()) + /** Stores completed operations for safe retries and idempotency enforcement. */ + const completedOperationsRef = useRef< + Map + >(new Map()) // ------------------------------------------------------------------------- // Derived: msUntilExpiry — recomputed each render, no extra state needed @@ -503,6 +523,11 @@ export function useEmergencyTransfer({ config?.asset?.contractAddress, config?.networkId, config?.expiresAt, + config?.memo, + config?.quoteId, + config?.quoteHash, + config?.requestKey, + config?.nonce, state.phase, ]) @@ -557,7 +582,9 @@ export function useEmergencyTransfer({ }, []) const bindConfirmation = useCallback((): ConfirmationPayload | null => { - if (!state.reviewedConfig || !state.riskAcknowledged) return null + if (state.phase !== 'reviewing' || !state.reviewedConfig || !state.riskAcknowledged) + return null + if (isConfigExpired(state.reviewedConfig, getNow())) { dispatch({ type: 'EXPIRE' }) @@ -578,6 +605,10 @@ export function useEmergencyTransfer({ networkId: state.reviewedConfig.networkId, authorizedBy: state.reviewedConfig.authorizedBy, memo: state.reviewedConfig.memo, + quoteId: state.reviewedConfig.quoteId, + quoteHash: state.reviewedConfig.quoteHash, + requestKey: state.reviewedConfig.requestKey, + nonce: state.reviewedConfig.nonce, riskAcknowledged: true as const, acknowledgedText: RISK_ACKNOWLEDGEMENT_TEXT, } @@ -597,7 +628,9 @@ export function useEmergencyTransfer({ payloadRef.current = frozen dispatch({ type: 'BIND_CONFIRMATION', bindingKey }) return frozen - }, [state.reviewedConfig, state.riskAcknowledged, getNow]) + }, [state.phase, state.reviewedConfig, state.riskAcknowledged, getNow]) + + const submit = useCallback(async (): Promise => { // ---- Duplicate-submit guard ---- @@ -607,7 +640,8 @@ export function useEmergencyTransfer({ } const payload = payloadRef.current - if (!payload || state.phase !== 'confirmed') return + if (!payload || (state.phase !== 'confirmed' && state.phase !== 'succeeded')) return + // ---- Re-check expiry ---- if (getNow() >= payload.expiresAt) { @@ -641,10 +675,40 @@ export function useEmergencyTransfer({ return } - // ---- Duplicate binding-key guard ---- - if (succeededKeysRef.current.has(payload.bindingKey)) { - dispatch({ type: 'DUPLICATE_BLOCKED' }) - return + // ---- Idempotency & Safe Retry vs Conflicting Key Guard ---- + const keysToCheck = [ + payload.bindingKey, + payload.requestKey, + payload.nonce, + ].filter(Boolean) as string[] + + for (const key of keysToCheck) { + const record = completedOperationsRef.current.get(key) + if (record) { + const matches = + record.payload.configId === payload.configId && + record.payload.recipient === payload.recipient && + record.payload.amountRaw === payload.amountRaw && + record.payload.asset.symbol === payload.asset.symbol && + record.payload.asset.contractAddress === payload.asset.contractAddress && + record.payload.networkId === payload.networkId && + record.payload.quoteId === payload.quoteId && + record.payload.quoteHash === payload.quoteHash && + record.payload.nonce === payload.nonce + + if (matches) { + // Safe retry: return deterministic result without re-executing provider + dispatch({ type: 'SUBMIT_SUCCESS', txHash: record.result.txHash }) + return + } else { + // Conflicting key reuse: reject attempt and leave zero partial state + dispatch({ + type: 'CONFLICTING_KEY_REUSED', + reason: `Request key "${key}" was already used with conflicting transfer parameters.`, + }) + return + } + } } submittingRef.current = true @@ -652,7 +716,11 @@ export function useEmergencyTransfer({ try { const { txHash } = await provider(payload) - succeededKeysRef.current.add(payload.bindingKey) + const resultObj = { txHash } + const entry = { payload, result: resultObj } + for (const key of keysToCheck) { + completedOperationsRef.current.set(key, entry) + } dispatch({ type: 'SUBMIT_SUCCESS', txHash }) } catch (err: unknown) { const msg = @@ -667,6 +735,7 @@ export function useEmergencyTransfer({ } }, [config, state.phase, state.reviewedConfig, provider, getNow]) + const dismiss = useCallback(() => { payloadRef.current = null submittingRef.current = false diff --git a/meridian-web/lib/validations/emergency-transfer.test.ts b/meridian-web/lib/validations/emergency-transfer.test.ts index 01784de9..278f46ad 100644 --- a/meridian-web/lib/validations/emergency-transfer.test.ts +++ b/meridian-web/lib/validations/emergency-transfer.test.ts @@ -396,4 +396,41 @@ describe('assertPayloadMatchesConfig', () => { expect(message).toMatch(/amountRaw/) expect(message).toMatch(/networkId/) }) + + it('throws when quoteId differs', () => { + const payloadWithQuote = ConfirmationPayloadSchema.parse({ + ...basePayload, + quoteId: 'quote-100', + }) + expect(() => + assertPayloadMatchesConfig(payloadWithQuote, { + ...baseConfig, + quoteId: 'quote-200', + }), + ).toThrow(/quoteId/) + }) + + it('throws when requestKey or nonce differs', () => { + const payloadWithKeys = ConfirmationPayloadSchema.parse({ + ...basePayload, + requestKey: 'req-key-1', + nonce: 'nonce-1', + }) + expect(() => + assertPayloadMatchesConfig(payloadWithKeys, { + ...baseConfig, + requestKey: 'req-key-2', + nonce: 'nonce-1', + }), + ).toThrow(/requestKey/) + + expect(() => + assertPayloadMatchesConfig(payloadWithKeys, { + ...baseConfig, + requestKey: 'req-key-1', + nonce: 'nonce-2', + }), + ).toThrow(/nonce/) + }) }) + diff --git a/meridian-web/lib/validations/emergency-transfer.ts b/meridian-web/lib/validations/emergency-transfer.ts index 6e34ceb9..80d55920 100644 --- a/meridian-web/lib/validations/emergency-transfer.ts +++ b/meridian-web/lib/validations/emergency-transfer.ts @@ -29,7 +29,7 @@ const nonEmptyString = z.string().trim().min(1, 'Required') * Allows both EVM (0x…) and non-EVM (Solana, Stellar) formats. */ const recipientAddress = nonEmptyString - .min(26, 'Recipient address is too short') + .min(26, 'Invalid recipient address format') .max(128, 'Recipient address is too long') .refine( (v) => @@ -45,7 +45,18 @@ const recipientAddress = nonEmptyString */ const rawAmountString = nonEmptyString .regex(/^\d+$/, 'Amount must be a non-negative integer string (smallest unit)') - .refine((v) => BigInt(v) > 0n, 'Amount must be greater than zero') + .refine( + (v) => { + try { + return BigInt(v) > BigInt(0) + } catch { + return false + } + }, + 'Amount must be greater than zero', + ) + + /** Token symbol — letters, digits, hyphens, max 20 chars. */ const tokenSymbol = nonEmptyString @@ -117,6 +128,10 @@ export const EmergencyTransferConfigSchema = z .min(1, 'authorizedBy must identify the authorising principal') .nullable(), memo: z.string().max(256, 'Memo exceeds 256 characters').optional(), + quoteId: z.string().max(128, 'quoteId is too long').optional(), + quoteHash: z.string().max(128, 'quoteHash is too long').optional(), + requestKey: z.string().max(128, 'requestKey is too long').optional(), + nonce: z.string().max(128, 'nonce is too long').optional(), }) .strict() @@ -157,6 +172,10 @@ export const ConfirmationPayloadSchema = z networkId, authorizedBy: z.string().min(1).nullable(), memo: z.string().max(256).optional(), + quoteId: z.string().max(128).optional(), + quoteHash: z.string().max(128).optional(), + requestKey: z.string().max(128).optional(), + nonce: z.string().max(128).optional(), /** Must be true — the user must have ticked the risk acknowledgement. */ riskAcknowledged: z.literal(true, { errorMap: () => ({ message: 'Risk acknowledgement is required' }), @@ -215,6 +234,11 @@ export function assertPayloadMatchesConfig( asset: { symbol: string; contractAddress: string; decimals: number } networkId: string expiresAt: number + memo?: string + quoteId?: string + quoteHash?: string + requestKey?: string + nonce?: string }, ): void { const mismatches: string[] = [] @@ -235,6 +259,16 @@ export function assertPayloadMatchesConfig( mismatches.push('networkId') if (payload.expiresAt !== config.expiresAt) mismatches.push('expiresAt') + if (payload.memo !== config.memo) + mismatches.push('memo') + if (payload.quoteId !== config.quoteId) + mismatches.push('quoteId') + if (payload.quoteHash !== config.quoteHash) + mismatches.push('quoteHash') + if (payload.requestKey !== config.requestKey) + mismatches.push('requestKey') + if (payload.nonce !== config.nonce) + mismatches.push('nonce') if (mismatches.length > 0) { throw new Error( @@ -244,3 +278,4 @@ export function assertPayloadMatchesConfig( ) } } + diff --git a/meridian-web/models/emergency-transfer-config.ts b/meridian-web/models/emergency-transfer-config.ts index 99e3ee4f..5607b21a 100644 --- a/meridian-web/models/emergency-transfer-config.ts +++ b/meridian-web/models/emergency-transfer-config.ts @@ -65,15 +65,26 @@ export interface EmergencyTransferConfig { readonly authorizedBy: string | null /** Optional free-text memo that will be embedded in the transaction. */ readonly memo?: string + /** Authorized quote identifier tying transfer terms to policy approval. */ + readonly quoteId?: string + /** Hash digest of quote terms (rate, fees, output amount). */ + readonly quoteHash?: string + /** Client-side or backend-issued durable request key for idempotency. */ + readonly requestKey?: string + /** One-time nonce binding for replay protection. */ + readonly nonce?: string } /** Factory that creates a frozen, immutable config with a derived configId. */ export function createEmergencyTransferConfig( - params: Omit, + params: Omit & { + configId?: string + }, ): EmergencyTransferConfig { const now = Date.now() - const raw = `${params.recipient}:${params.amountRaw}:${params.asset.symbol}:${params.networkId}:${now}` - const configId = btoa(raw).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32) + const raw = `${params.quoteId ?? ''}:${params.requestKey ?? ''}:${params.nonce ?? ''}:${params.recipient}:${params.amountRaw}:${params.asset.symbol}:${params.networkId}:${now}` + const derivedConfigId = btoa(raw).replace(/[^a-zA-Z0-9]/g, '').slice(0, 32) + const configId = params.configId ?? derivedConfigId const config: EmergencyTransferConfig = Object.freeze({ ...params, @@ -106,6 +117,12 @@ export function configsMatch(a: EmergencyTransferConfig, b: EmergencyTransferCon a.asset.contractAddress === b.asset.contractAddress && a.asset.decimals === b.asset.decimals && a.networkId === b.networkId && - a.expiresAt === b.expiresAt + a.expiresAt === b.expiresAt && + a.memo === b.memo && + a.quoteId === b.quoteId && + a.quoteHash === b.quoteHash && + a.requestKey === b.requestKey && + a.nonce === b.nonce ) } + diff --git a/meridian-web/models/emergency-transfer-event.ts b/meridian-web/models/emergency-transfer-event.ts index cb43b738..1386e72a 100644 --- a/meridian-web/models/emergency-transfer-event.ts +++ b/meridian-web/models/emergency-transfer-event.ts @@ -22,6 +22,7 @@ export type EmergencyTransferEventType = | 'SUBMIT_SUCCEEDED' // Provider accepted the transaction | 'SUBMIT_FAILED' // Provider rejected the transaction | 'DUPLICATE_BLOCKED' // Duplicate submit attempt was blocked + | 'CONFLICTING_KEY_REUSED' // Request key reused with conflicting terms | 'EXPIRED' // Config expired before confirmation | 'CONFIG_CHANGED' // Underlying config changed, review invalidated | 'UNAUTHORIZED' // Policy / auth check failed @@ -85,6 +86,12 @@ export interface DuplicateBlockedEvent extends BaseEvent { readonly bindingKey: string } +export interface ConflictingKeyReusedEvent extends BaseEvent { + readonly eventType: 'CONFLICTING_KEY_REUSED' + readonly bindingKey: string + readonly reason: string +} + export interface ExpiredEvent extends BaseEvent { readonly eventType: 'EXPIRED' } @@ -117,6 +124,7 @@ export type EmergencyTransferEvent = | SubmitSucceededEvent | SubmitFailedEvent | DuplicateBlockedEvent + | ConflictingKeyReusedEvent | ExpiredEvent | ConfigChangedEvent | UnauthorizedEvent @@ -151,6 +159,10 @@ export function createEvent( export function deriveBindingKey(config: EmergencyTransferConfig): string { const payload = [ config.configId, + config.requestKey ?? '', + config.nonce ?? '', + config.quoteId ?? '', + config.quoteHash ?? '', config.recipient, config.amountRaw, config.asset.symbol, @@ -166,3 +178,4 @@ export function deriveBindingKey(config: EmergencyTransferConfig): string { } return `bk_${(h >>> 0).toString(16).padStart(8, '0')}` } +