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')}`
}
+