diff --git a/apps/swap-service/src/swaps/__tests__/create-swap-attribution.test.ts b/apps/swap-service/src/swaps/__tests__/create-swap-attribution.test.ts index 1c53e71..a69c34c 100644 --- a/apps/swap-service/src/swaps/__tests__/create-swap-attribution.test.ts +++ b/apps/swap-service/src/swaps/__tests__/create-swap-attribution.test.ts @@ -131,3 +131,41 @@ describe('createSwap partner attribution', () => { expect(findManyCalls[0]?.where?.isActive).toBe(true) }) }) + +describe('createSwap quote provenance', () => { + it('persists the quote mint time carried by the registration payload', async () => { + const { service, createCalls } = buildService(null) + + await service.createSwap(swapRequest({ quotedAt: '2026-09-01T12:00:00.000Z' })) + + expect(createCalls[0]?.data).toMatchObject({ + quotedAt: new Date('2026-09-01T12:00:00.000Z'), + }) + }) + + // null rather than a default now(): the resolver would trust a fabricated quote time + it('stores a null quote time when the payload carries none', async () => { + const { service, createCalls } = buildService(null) + + await service.createSwap(swapRequest()) + + expect(createCalls[0]?.data).toMatchObject({ quotedAt: null }) + }) + + it('drops a malformed quote timestamp rather than failing registration', async () => { + const { service, createCalls } = buildService(null) + + await service.createSwap(swapRequest({ quotedAt: 'not-a-date' })) + + expect(createCalls[0]?.data).toMatchObject({ quotedAt: null }) + }) + + // the route has no runtime validation, and epoch millis would otherwise parse as a valid date + it('rejects a non-string quote timestamp', async () => { + const { service, createCalls } = buildService(null) + + await service.createSwap(swapRequest({ quotedAt: 1756729200000 as unknown as string })) + + expect(createCalls[0]?.data).toMatchObject({ quotedAt: null }) + }) +}) diff --git a/apps/swap-service/src/swaps/swaps.service.ts b/apps/swap-service/src/swaps/swaps.service.ts index 66c7f2a..f43064d 100644 --- a/apps/swap-service/src/swaps/swaps.service.ts +++ b/apps/swap-service/src/swaps/swaps.service.ts @@ -36,6 +36,7 @@ import { computeSellAmountUsd, describeError, fetchUsdPrices, + toQuotedAt, toSwap, toSwapperSwap, } from './utils' @@ -121,6 +122,7 @@ export class SwapsService { affiliateBps: data.affiliateBps, shapeshiftBps: data.shapeshiftBps, origin: data.origin ?? null, + quotedAt: toQuotedAt(data.quotedAt), affiliateFeeAssetId, }, }), diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index 5bed442..0be9c41 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -42,6 +42,14 @@ export const toSwap = (swap: PrismaSwap): Swap => ({ affiliateVerificationDetails: toAffiliateVerificationDetails(swap.affiliateVerificationDetails), }) +export const toQuotedAt = (value: string | undefined): Date | null => { + if (typeof value !== 'string') return null + + const parsed = new Date(value) + + return Number.isNaN(parsed.getTime()) ? null : parsed +} + export const toSwapperSwap = (swap: Swap): SwapperSwap => ({ ...swap, diff --git a/apps/swap-service/src/verification/__tests__/fixtures/mayachain/swap.ts b/apps/swap-service/src/verification/__tests__/fixtures/mayachain/swap.ts index c445c1b..25c94bd 100644 --- a/apps/swap-service/src/verification/__tests__/fixtures/mayachain/swap.ts +++ b/apps/swap-service/src/verification/__tests__/fixtures/mayachain/swap.ts @@ -78,4 +78,8 @@ export default { actualAffiliateFeeAmountCryptoBaseUnit: null, shapeshiftBps: 10, verificationStatus: 'PENDING', + quotedAt: null, + attributionStatus: 'PENDING', + attributionResolvedAt: null, + attributionDetails: null, } satisfies Swap diff --git a/apps/swap-service/src/verification/__tests__/fixtures/near/swap.ts b/apps/swap-service/src/verification/__tests__/fixtures/near/swap.ts index 63a593d..c26d8be 100644 --- a/apps/swap-service/src/verification/__tests__/fixtures/near/swap.ts +++ b/apps/swap-service/src/verification/__tests__/fixtures/near/swap.ts @@ -72,4 +72,8 @@ export default { affiliateFeeAssetId: 'eip155:1/slip44:60', actualAffiliateFeeAmountCryptoBaseUnit: null, shapeshiftBps: 10, + quotedAt: null, + attributionStatus: 'PENDING', + attributionResolvedAt: null, + attributionDetails: null, } satisfies Swap diff --git a/apps/swap-service/src/verification/__tests__/fixtures/relay/swap.ts b/apps/swap-service/src/verification/__tests__/fixtures/relay/swap.ts index 278ee67..01feec7 100644 --- a/apps/swap-service/src/verification/__tests__/fixtures/relay/swap.ts +++ b/apps/swap-service/src/verification/__tests__/fixtures/relay/swap.ts @@ -78,4 +78,8 @@ export default { affiliateFeeAssetId: 'eip155:8453/erc20:0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', actualAffiliateFeeAmountCryptoBaseUnit: '6000000000000', shapeshiftBps: 60, + quotedAt: null, + attributionStatus: 'PENDING', + attributionResolvedAt: null, + attributionDetails: null, } satisfies Swap diff --git a/apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts index 47cc9f4..b86a50a 100644 --- a/apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts +++ b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts @@ -78,4 +78,8 @@ export default { actualAffiliateFeeAmountCryptoBaseUnit: null, shapeshiftBps: 10, verificationStatus: 'PENDING', + quotedAt: null, + attributionStatus: 'PENDING', + attributionResolvedAt: null, + attributionDetails: null, } satisfies Swap diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 301bb8e..78b0d8a 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -82,6 +82,7 @@ export interface CreateSwapDto { partnerBps?: number partnerCode?: string origin?: 'web' | 'api' + quotedAt?: string } export interface UpdateSwapStatusDto { diff --git a/prisma/migrations/20260901000000_swap_quote_provenance/migration.sql b/prisma/migrations/20260901000000_swap_quote_provenance/migration.sql new file mode 100644 index 0000000..28e038d --- /dev/null +++ b/prisma/migrations/20260901000000_swap_quote_provenance/migration.sql @@ -0,0 +1,12 @@ +-- Ordering key and resolution outcome for attribution claims contending for one transaction. + +-- existing rows stay NULL: createdAt is registration time, not when the quote was minted +ALTER TABLE "swaps" ADD COLUMN "quotedAt" TIMESTAMP(3); + +CREATE TYPE "AttributionStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED', 'DISPUTED'); +ALTER TABLE "swaps" ADD COLUMN "attributionStatus" "AttributionStatus" NOT NULL DEFAULT 'PENDING'; +ALTER TABLE "swaps" ADD COLUMN "attributionResolvedAt" TIMESTAMP(3); +ALTER TABLE "swaps" ADD COLUMN "attributionDetails" JSONB; + +-- the existing composite index leads with status, so it cannot serve a lookup on sellTxHash alone +CREATE INDEX "swaps_sellTxHash_idx" ON "swaps"("sellTxHash"); diff --git a/prisma/schema/swap-service.prisma b/prisma/schema/swap-service.prisma index 5bd3ebd..e719faa 100644 --- a/prisma/schema/swap-service.prisma +++ b/prisma/schema/swap-service.prisma @@ -52,16 +52,28 @@ model Swap { affiliateFeeAssetId String? actualAffiliateFeeAmountCryptoBaseUnit String? verificationStatus VerificationStatus @default(PENDING) + quotedAt DateTime? + attributionStatus AttributionStatus @default(PENDING) + attributionResolvedAt DateTime? + attributionDetails Json? @@index([referralCode]) @@index([partnerAddress]) @@index([partnerCode]) @@index([status, sellTxHash]) + @@index([sellTxHash]) @@index([verificationStatus, status]) @@index([userId]) @@map("swaps") } +enum AttributionStatus { + PENDING + ACCEPTED + REJECTED + DISPUTED +} + enum VerificationStatus { PENDING SUCCESS