diff --git a/src/__tests__/pendingTransactions.test.ts b/src/__tests__/pendingTransactions.test.ts new file mode 100644 index 00000000..bfa54ab8 --- /dev/null +++ b/src/__tests__/pendingTransactions.test.ts @@ -0,0 +1,313 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +const addCorsCacheBustingHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); + +jest.mock( + '@/lib/cors', + () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, + cors: corsMock, + }), + { virtual: true }, +); + +const verifyJwtMock = jest.fn<(token: string | undefined) => { address: string } | null>(); + +jest.mock( + '@/lib/verifyJwt', + () => ({ + __esModule: true, + verifyJwt: verifyJwtMock, + }), + { virtual: true }, +); + +const createCallerMock = jest.fn(); + +jest.mock( + '@/server/api/root', + () => ({ + __esModule: true, + createCaller: createCallerMock, + }), + { virtual: true }, +); + +const dbMock = { __type: 'dbMock' }; + +jest.mock( + '@/server/db', + () => ({ + __esModule: true, + db: dbMock, + }), + { virtual: true }, +); + +type ResponseMock = NextApiResponse & { statusCode?: number }; + +function createMockResponse(): ResponseMock { + const res = { + statusCode: undefined as number | undefined, + status: jest.fn<(code: number) => NextApiResponse>(), + json: jest.fn<(payload: unknown) => unknown>(), + end: jest.fn<() => void>(), + setHeader: jest.fn<(name: string, value: string) => void>(), + }; + + res.status.mockImplementation((code: number) => { + res.statusCode = code; + return res as unknown as NextApiResponse; + }); + + res.json.mockImplementation((payload: unknown) => payload); + + return res as unknown as ResponseMock; +} + +const walletGetWalletMock = jest.fn<(args: unknown) => Promise>(); +const transactionGetPendingTransactionsMock = jest.fn<(args: unknown) => Promise>(); +const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined); + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import('../pages/api/v1/pendingTransactions')); +}); + +beforeEach(() => { + jest.clearAllMocks(); + + walletGetWalletMock.mockReset(); + transactionGetPendingTransactionsMock.mockReset(); + verifyJwtMock.mockReset(); + createCallerMock.mockReset(); + addCorsCacheBustingHeadersMock.mockReset(); + corsMock.mockReset(); + + corsMock.mockResolvedValue(undefined); + addCorsCacheBustingHeadersMock.mockImplementation(() => { + // no-op + }); + + createCallerMock.mockReturnValue({ + wallet: { getWallet: walletGetWalletMock }, + transaction: { getPendingTransactions: transactionGetPendingTransactionsMock }, + }); +}); + +afterEach(() => { + consoleErrorSpy.mockClear(); +}); + +afterAll(() => { + consoleErrorSpy.mockRestore(); +}); + +describe('pendingTransactions API route', () => { + it('handles OPTIONS preflight requests', async () => { + const req = { + method: 'OPTIONS', + headers: { authorization: 'Bearer token' }, + query: {}, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(addCorsCacheBustingHeadersMock).toHaveBeenCalledWith(res); + expect(corsMock).toHaveBeenCalledWith(req, res); + expect(res.status).toHaveBeenCalledWith(200); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(res.end).toHaveBeenCalled(); + }); + + it('returns 405 for unsupported methods', async () => { + const req = { + method: 'POST', + headers: { authorization: 'Bearer token' }, + query: {}, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(405); + expect(res.json).toHaveBeenCalledWith({ error: 'Method Not Allowed' }); + }); + + it('returns pending transactions for valid request', async () => { + const address = 'addr_test1qpvalidaddress'; + const walletId = 'wallet-valid'; + const token = 'valid-token'; + const pendingTransactions = [{ id: 'tx-1' }, { id: 'tx-2' }]; + + verifyJwtMock.mockReturnValue({ address }); + walletGetWalletMock.mockResolvedValue({ + id: walletId, + signersAddresses: [address], + }); + transactionGetPendingTransactionsMock.mockResolvedValue(pendingTransactions); + + const req = { + method: 'GET', + headers: { authorization: `Bearer ${token}` }, + query: { walletId, address }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(addCorsCacheBustingHeadersMock).toHaveBeenCalledWith(res); + expect(corsMock).toHaveBeenCalledWith(req, res); + expect(verifyJwtMock).toHaveBeenCalledWith(token); + expect(createCallerMock).toHaveBeenCalledWith({ + db: dbMock, + session: expect.objectContaining({ + user: { id: address }, + expires: expect.any(String), + }), + }); + expect(walletGetWalletMock).toHaveBeenCalledWith({ walletId, address }); + expect(transactionGetPendingTransactionsMock).toHaveBeenCalledWith({ walletId }); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(pendingTransactions); + }); + + it('returns 401 when authorization header is missing', async () => { + const req = { + method: 'GET', + headers: {}, + query: { walletId: 'wallet', address: 'addr_test1qpmissingauth' }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized - Missing token' }); + expect(verifyJwtMock).not.toHaveBeenCalled(); + expect(createCallerMock).not.toHaveBeenCalled(); + }); + + it('returns 401 when token verification fails', async () => { + verifyJwtMock.mockReturnValue(null); + + const req = { + method: 'GET', + headers: { authorization: 'Bearer invalid-token' }, + query: { walletId: 'wallet-id', address: 'addr_test1qpinvalidtoken' }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(verifyJwtMock).toHaveBeenCalledWith('invalid-token'); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid or expired token' }); + expect(createCallerMock).not.toHaveBeenCalled(); + }); + + it('returns 403 when JWT address mismatches query address', async () => { + verifyJwtMock.mockReturnValue({ address: 'addr_test1qpjwtaddress' }); + + const req = { + method: 'GET', + headers: { authorization: 'Bearer mismatch-token' }, + query: { + walletId: 'wallet-mismatch', + address: 'addr_test1qpqueryaddress', + }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'Address mismatch' }); + expect(createCallerMock).not.toHaveBeenCalled(); + }); + + it('returns 400 when address parameter is invalid', async () => { + verifyJwtMock.mockReturnValue({ address: 'addr_test1qpaddressparam' }); + + const req = { + method: 'GET', + headers: { authorization: 'Bearer token' }, + query: { walletId: 'wallet-id', address: ['addr'] }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid address parameter' }); + }); + + it('returns 400 when walletId parameter is invalid', async () => { + verifyJwtMock.mockReturnValue({ address: 'addr_test1qpwalletparam' }); + + const req = { + method: 'GET', + headers: { authorization: 'Bearer token' }, + query: { walletId: ['wallet'], address: 'addr_test1qpwalletparam' }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid walletId parameter' }); + }); + + it('returns 404 when wallet is not found', async () => { + const address = 'addr_test1qpwalletmissing'; + const walletId = 'wallet-missing'; + + verifyJwtMock.mockReturnValue({ address }); + walletGetWalletMock.mockResolvedValue(null); + + const req = { + method: 'GET', + headers: { authorization: 'Bearer token' }, + query: { walletId, address }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(walletGetWalletMock).toHaveBeenCalledWith({ walletId, address }); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Wallet not found' }); + expect(transactionGetPendingTransactionsMock).not.toHaveBeenCalled(); + }); + + it('returns 500 when fetching pending transactions fails', async () => { + const address = 'addr_test1qperrorcase'; + const walletId = 'wallet-error'; + const failure = new Error('database unavailable'); + + verifyJwtMock.mockReturnValue({ address }); + walletGetWalletMock.mockResolvedValue({ id: walletId }); + transactionGetPendingTransactionsMock.mockRejectedValue(failure); + + const req = { + method: 'GET', + headers: { authorization: 'Bearer token' }, + query: { walletId, address }, + } as unknown as NextApiRequest; + const res = createMockResponse(); + + await handler(req, res); + + expect(transactionGetPendingTransactionsMock).toHaveBeenCalledWith({ walletId }); + expect(consoleErrorSpy).toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: 'Internal Server Error' }); + }); +}); + + diff --git a/src/__tests__/signTransaction.test.ts b/src/__tests__/signTransaction.test.ts new file mode 100644 index 00000000..39c1958a --- /dev/null +++ b/src/__tests__/signTransaction.test.ts @@ -0,0 +1,698 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +const addCorsCacheBustingHeadersMock = jest.fn<(res: NextApiResponse) => void>(); +const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); + +jest.mock( + '@/lib/cors', + () => ({ + __esModule: true, + addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, + cors: corsMock, + }), + { virtual: true }, +); + +const verifyJwtMock = jest.fn<(token: string | undefined) => { address: string } | null>(); + +jest.mock( + '@/lib/verifyJwt', + () => ({ + __esModule: true, + verifyJwt: verifyJwtMock, + }), + { virtual: true }, +); + +const createCallerMock = jest.fn(); + +jest.mock( + '@/server/api/root', + () => ({ + __esModule: true, + createCaller: createCallerMock, + }), + { virtual: true }, +); + +const dbTransactionFindUniqueMock = jest.fn<(args: unknown) => Promise>(); +const dbTransactionUpdateManyMock = jest.fn< + (args: { + where: unknown; + data: unknown; + }) => Promise<{ count: number }> +>(); + +const dbMock = { + transaction: { + findUnique: dbTransactionFindUniqueMock, + updateMany: dbTransactionUpdateManyMock, + }, +}; + +jest.mock( + '@/server/db', + () => ({ + __esModule: true, + db: dbMock, + }), + { virtual: true }, +); + +const getProviderMock = jest.fn<(network: number) => unknown>(); + +jest.mock( + '@/utils/get-provider', + () => ({ + __esModule: true, + getProvider: getProviderMock, + }), + { virtual: true }, +); + +const addressToNetworkMock = jest.fn<(address: string) => number>(); + +jest.mock( + '@/utils/multisigSDK', + () => ({ + __esModule: true, + addressToNetwork: addressToNetworkMock, + }), + { virtual: true }, +); + +const resolvePaymentKeyHashMock = jest.fn<(address: string) => string>(); + +jest.mock( + '@meshsdk/core', + () => ({ + __esModule: true, + resolvePaymentKeyHash: resolvePaymentKeyHashMock, + }), + { virtual: true }, +); + +const witnessKeyHashHex = '00112233'; + +class MockEd25519Signature { + constructor(public hex: string) {} + + static from_hex(hex: string) { + return new MockEd25519Signature(hex); + } + + to_bytes() { + return Buffer.from(this.hex, 'hex'); + } +} + +class MockPublicKey { + constructor(public hex: string) {} + + static from_hex(hex: string) { + return new MockPublicKey(hex); + } + + hash() { + return { + to_bytes: () => Buffer.from(witnessKeyHashHex, 'hex'), + }; + } + + verify() { + return true; + } + + to_bech32() { + return `mock_bech32_${this.hex.slice(0, 8)}`; + } +} + +class MockVkey { + constructor(private readonly publicKey: MockPublicKey) {} + + static new(publicKey: MockPublicKey) { + return new MockVkey(publicKey); + } + + public_key() { + return this.publicKey; + } +} + +class MockVkeywitness { + constructor( + private readonly vkeyInstance: MockVkey, + private readonly signatureInstance: MockEd25519Signature, + ) {} + + static new(vkey: MockVkey, signature: MockEd25519Signature) { + return new MockVkeywitness(vkey, signature); + } + + vkey() { + return this.vkeyInstance; + } + + signature() { + return this.signatureInstance; + } +} + +class MockVkeywitnesses { + private static lastItems: MockVkeywitness[] = []; + + static reset() { + MockVkeywitnesses.lastItems = []; + } + + private readonly items: MockVkeywitness[]; + + constructor(items: MockVkeywitness[] = []) { + this.items = [...items]; + } + + static new() { + return new MockVkeywitnesses(); + } + + static from_bytes() { + return new MockVkeywitnesses(MockVkeywitnesses.lastItems); + } + + to_bytes() { + MockVkeywitnesses.lastItems = [...this.items]; + return new Uint8Array(); + } + + len() { + return this.items.length; + } + + get(index: number) { + return this.items[index]; + } + + add(item: MockVkeywitness) { + this.items.push(item); + } +} + +class MockTransactionBody { + constructor(private readonly bytes: Uint8Array) {} + + static from_bytes(bytes: Uint8Array) { + return new MockTransactionBody(bytes); + } + + to_bytes() { + return this.bytes; + } +} + +class MockTransactionWitnessSet { + constructor(private vkeysInstance: MockVkeywitnesses | undefined) {} + + static from_bytes() { + return new MockTransactionWitnessSet(MockVkeywitnesses.from_bytes()); + } + + to_bytes() { + return this.vkeysInstance?.to_bytes() ?? new Uint8Array(); + } + + vkeys() { + return this.vkeysInstance; + } + + set_vkeys(vkeys: MockVkeywitnesses) { + this.vkeysInstance = vkeys; + } +} + +class MockTransaction { + private isValid = true; + + constructor( + private readonly bodyInstance: MockTransactionBody, + private readonly witnessSetInstance: MockTransactionWitnessSet, + private readonly auxData: unknown, + private hexValue: string, + ) {} + + static from_hex(hex: string) { + const body = new MockTransactionBody(new Uint8Array([1, 2])); + const witnessSet = new MockTransactionWitnessSet(MockVkeywitnesses.from_bytes()); + return new MockTransaction(body, witnessSet, { type: 'aux' }, hex); + } + + static new( + body: MockTransactionBody, + witnessSet: MockTransactionWitnessSet, + auxData: unknown, + ) { + return new MockTransaction(body, witnessSet, auxData, 'updated-tx-hex'); + } + + body() { + return this.bodyInstance; + } + + witness_set() { + return this.witnessSetInstance; + } + + auxiliary_data() { + return this.auxData; + } + + is_valid() { + return this.isValid; + } + + set_is_valid(value: boolean) { + this.isValid = value; + } + + to_hex() { + return this.hexValue; + } +} + +const calculateTxHashMock = jest.fn<(hex: string) => string>(); + +const cslMock = { + Transaction: MockTransaction, + TransactionBody: MockTransactionBody, + TransactionWitnessSet: MockTransactionWitnessSet, + PublicKey: MockPublicKey, + Ed25519Signature: MockEd25519Signature, + Vkey: MockVkey, + Vkeywitness: MockVkeywitness, + Vkeywitnesses: MockVkeywitnesses, +}; + +jest.mock( + '@meshsdk/core-csl', + () => ({ + __esModule: true, + csl: cslMock, + calculateTxHash: calculateTxHashMock, + }), + { virtual: true }, +); + +const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); +const consoleWarnSpy = jest + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + +type ResponseMock = NextApiResponse & { statusCode?: number }; + +function createMockResponse(): ResponseMock { + const res = { + statusCode: undefined as number | undefined, + status: jest.fn<(code: number) => NextApiResponse>(), + json: jest.fn<(payload: unknown) => unknown>(), + end: jest.fn<() => void>(), + setHeader: jest.fn<(name: string, value: string) => void>(), + }; + + res.status.mockImplementation((code: number) => { + res.statusCode = code; + return res as unknown as NextApiResponse; + }); + + res.json.mockImplementation((payload: unknown) => payload); + + return res as unknown as ResponseMock; +} + +const walletGetWalletMock = jest.fn<(args: unknown) => Promise>(); + +let handler: (req: NextApiRequest, res: NextApiResponse) => Promise; + +beforeAll(async () => { + ({ default: handler } = await import('../pages/api/v1/signTransaction')); +}); + +beforeEach(() => { + jest.clearAllMocks(); + MockVkeywitnesses.reset(); + + walletGetWalletMock.mockReset(); + dbTransactionFindUniqueMock.mockReset(); + dbTransactionUpdateManyMock.mockReset(); + getProviderMock.mockReset(); + addressToNetworkMock.mockReset(); + resolvePaymentKeyHashMock.mockReset(); + calculateTxHashMock.mockReset(); + corsMock.mockReset(); + addCorsCacheBustingHeadersMock.mockReset(); + createCallerMock.mockReset(); + verifyJwtMock.mockReset(); + + corsMock.mockResolvedValue(undefined); + addCorsCacheBustingHeadersMock.mockImplementation(() => { + // no-op for tests + }); + calculateTxHashMock.mockReturnValue('deadbeef'); + resolvePaymentKeyHashMock.mockReturnValue(witnessKeyHashHex); + addressToNetworkMock.mockReturnValue(0); + + createCallerMock.mockReturnValue({ + wallet: { getWallet: walletGetWalletMock }, + }); +}); + +afterEach(() => { + consoleErrorSpy.mockClear(); + consoleWarnSpy.mockClear(); +}); + +afterAll(() => { + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); +}); + +describe('signTransaction API route', () => { + it('updates transaction when payload is valid', async () => { + const address = 'addr_test1qpl3w9v4l5qhxk778exampleaddress'; + const walletId = 'wallet-id-123'; + const transactionId = 'transaction-id-456'; + const signatureHex = 'aa'.repeat(64); + const keyHex = 'bb'.repeat(64); + + verifyJwtMock.mockReturnValue({ address }); + + walletGetWalletMock.mockResolvedValue({ + id: walletId, + type: 'atLeast', + numRequiredSigners: 1, + signersAddresses: [address], + }); + + const transactionRecord = { + id: transactionId, + walletId, + state: 0, + signedAddresses: [] as string[], + rejectedAddresses: [] as string[], + txCbor: 'stored-tx-hex', + txHash: null as string | null, + txJson: '{}', + }; + + const updatedTransaction = { + ...transactionRecord, + signedAddresses: [address], + txCbor: 'updated-tx-hex', + state: 1, + txHash: 'provided-hash', + txJson: '{"multisig":{"state":1}}', + }; + + dbTransactionFindUniqueMock + .mockResolvedValueOnce(transactionRecord) + .mockResolvedValueOnce(updatedTransaction); + + dbTransactionUpdateManyMock.mockResolvedValue({ count: 1 }); + + const submitTxMock = jest.fn<(txHex: string) => Promise>(); + submitTxMock.mockResolvedValue('provided-hash'); + getProviderMock.mockReturnValue({ submitTx: submitTxMock }); + + const req = { + method: 'POST', + headers: { authorization: 'Bearer valid-token' }, + body: { + walletId, + transactionId, + address, + signature: signatureHex, + key: keyHex, + txHash: 'provided-hash', + }, + } as unknown as NextApiRequest; + + const res = createMockResponse(); + + await handler(req, res); + + expect(addCorsCacheBustingHeadersMock).toHaveBeenCalledWith(res); + expect(corsMock).toHaveBeenCalledWith(req, res); + expect(verifyJwtMock).toHaveBeenCalledWith('valid-token'); + expect(createCallerMock).toHaveBeenCalledWith({ + db: dbMock, + session: expect.objectContaining({ + user: { id: address }, + expires: expect.any(String), + }), + }); + expect(walletGetWalletMock).toHaveBeenCalledWith({ walletId, address }); + expect(dbTransactionFindUniqueMock).toHaveBeenNthCalledWith(1, { + where: { id: transactionId }, + }); + expect(getProviderMock).toHaveBeenCalledWith(0); + expect(submitTxMock).toHaveBeenCalledWith('updated-tx-hex'); + expect(dbTransactionUpdateManyMock).toHaveBeenCalledWith({ + where: { + id: transactionId, + signedAddresses: { equals: [] }, + rejectedAddresses: { equals: [] }, + txCbor: 'stored-tx-hex', + txJson: '{}', + }, + data: expect.objectContaining({ + signedAddresses: { set: [address] }, + rejectedAddresses: { set: [] }, + txCbor: 'updated-tx-hex', + state: 1, + txHash: 'provided-hash', + txJson: expect.any(String), + }), + }); + expect(dbTransactionFindUniqueMock).toHaveBeenNthCalledWith(2, { + where: { id: transactionId }, + }); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + transaction: updatedTransaction, + submitted: true, + txHash: 'provided-hash', + }); + }); + + it('returns 403 when JWT address mismatches request address', async () => { + verifyJwtMock.mockReturnValue({ address: 'addr_test1qpotheraddress' }); + + const req = { + method: 'POST', + headers: { authorization: 'Bearer mismatch-token' }, + body: { + walletId: 'wallet-mismatch', + transactionId: 'tx-mismatch', + address: 'addr_test1qprequestaddress', + signature: 'aa'.repeat(64), + key: 'bb'.repeat(64), + }, + } as unknown as NextApiRequest; + + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'Address mismatch' }); + expect(createCallerMock).not.toHaveBeenCalled(); + expect(walletGetWalletMock).not.toHaveBeenCalled(); + }); + + it('returns 404 when wallet is not found', async () => { + const address = 'addr_test1qpwalletmissing'; + verifyJwtMock.mockReturnValue({ address }); + + walletGetWalletMock.mockResolvedValue(null); + + const req = { + method: 'POST', + headers: { authorization: 'Bearer valid-token' }, + body: { + walletId: 'wallet-missing', + transactionId: 'tx-any', + address, + signature: 'aa'.repeat(64), + key: 'bb'.repeat(64), + }, + } as unknown as NextApiRequest; + + const res = createMockResponse(); + + await handler(req, res); + + expect(walletGetWalletMock).toHaveBeenCalledWith({ + walletId: 'wallet-missing', + address, + }); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Wallet not found' }); + expect(dbTransactionFindUniqueMock).not.toHaveBeenCalled(); + }); + + it('returns 404 when transaction is not found', async () => { + const address = 'addr_test1qptransactionmissing'; + const walletId = 'wallet-without-transaction'; + const transactionId = 'missing-tx'; + verifyJwtMock.mockReturnValue({ address }); + + walletGetWalletMock.mockResolvedValue({ + id: walletId, + type: 'atLeast', + numRequiredSigners: 1, + signersAddresses: [address], + }); + dbTransactionFindUniqueMock.mockResolvedValue(null); + + const req = { + method: 'POST', + headers: { authorization: 'Bearer valid-token' }, + body: { + walletId, + transactionId, + address, + signature: 'aa'.repeat(64), + key: 'bb'.repeat(64), + }, + } as unknown as NextApiRequest; + + const res = createMockResponse(); + + await handler(req, res); + + expect(dbTransactionFindUniqueMock).toHaveBeenCalledWith({ + where: { id: transactionId }, + }); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Transaction not found' }); + }); + + it('returns 401 when authorization header is missing', async () => { + const req = { + method: 'POST', + headers: {}, + body: {}, + } as unknown as NextApiRequest; + + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized - Missing token' }); + expect(verifyJwtMock).not.toHaveBeenCalled(); + expect(createCallerMock).not.toHaveBeenCalled(); + }); + + it('returns 409 when address already signed the transaction', async () => { + const address = 'addr_test1qpmockalready'; + const walletId = 'wallet-id-dupe'; + const transactionId = 'transaction-id-dupe'; + const signatureHex = 'aa'.repeat(64); + const keyHex = 'bb'.repeat(64); + + verifyJwtMock.mockReturnValue({ address }); + + walletGetWalletMock.mockResolvedValue({ + id: walletId, + type: 'atLeast', + numRequiredSigners: 2, + signersAddresses: [address, 'addr_test1qpother'], + }); + + dbTransactionFindUniqueMock.mockResolvedValue({ + id: transactionId, + walletId, + state: 0, + signedAddresses: [address], + rejectedAddresses: [], + txCbor: 'stored-tx-hex', + txHash: null, + }); + + const req = { + method: 'POST', + headers: { authorization: 'Bearer valid-token' }, + body: { + walletId, + transactionId, + address, + signature: signatureHex, + key: keyHex, + }, + } as unknown as NextApiRequest; + + const res = createMockResponse(); + + await handler(req, res); + + expect(walletGetWalletMock).toHaveBeenCalledWith({ walletId, address }); + expect(dbTransactionFindUniqueMock).toHaveBeenCalledWith({ where: { id: transactionId } }); + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith({ + error: 'Address has already signed this transaction', + }); + expect(dbTransactionUpdateManyMock).not.toHaveBeenCalled(); + expect(getProviderMock).not.toHaveBeenCalled(); + }); + + it('returns 500 when stored transaction is missing txCbor', async () => { + const address = 'addr_test1qpmissingtxcbor'; + const walletId = 'wallet-missing-txcbor'; + const transactionId = 'transaction-missing-txcbor'; + + verifyJwtMock.mockReturnValue({ address }); + walletGetWalletMock.mockResolvedValue({ + id: walletId, + type: 'atLeast', + numRequiredSigners: 1, + signersAddresses: [address], + }); + dbTransactionFindUniqueMock.mockResolvedValue({ + id: transactionId, + walletId, + state: 0, + signedAddresses: [] as string[], + rejectedAddresses: [] as string[], + txCbor: '', + txHash: null as string | null, + }); + + const req = { + method: 'POST', + headers: { authorization: 'Bearer valid-token' }, + body: { + walletId, + transactionId, + address, + signature: 'aa'.repeat(64), + key: 'bb'.repeat(64), + }, + } as unknown as NextApiRequest; + + const res = createMockResponse(); + + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: 'Stored transaction is missing txCbor', + }); + expect(dbTransactionUpdateManyMock).not.toHaveBeenCalled(); + }); + +}); + diff --git a/src/pages/api/v1/README.md b/src/pages/api/v1/README.md index fb48485d..42cc0cf0 100644 --- a/src/pages/api/v1/README.md +++ b/src/pages/api/v1/README.md @@ -43,21 +43,19 @@ A comprehensive REST API implementation for the multisig wallet application, pro - **Response**: Transaction object with ID, state, and metadata - **Error Handling**: 400 (validation), 401 (auth), 403 (authorization), 500 (server) -#### `signTransaction.ts` - POST `/api/v1/signTransaction` +#### `pendingTransactions.ts` - GET `/api/v1/pendingTransactions` -- **Purpose**: Record a signature for a pending multisig transaction +- **Purpose**: Retrieve all pending multisig transactions for a wallet - **Authentication**: Required (JWT Bearer token) - **Features**: - - Signature tracking with duplicate and rejection safeguards - - Wallet membership validation and JWT address enforcement - - Threshold detection with automatic submission when the final signature is collected -- **Request Body**: + - Wallet ownership validation and JWT address enforcement + - Pending transaction listing with signature status metadata + - Supports multisig coordination for outstanding approvals +- **Query Parameters**: - `walletId`: Wallet identifier - - `transactionId`: Pending transaction identifier - - `address`: Signer address - - `signedTx`: CBOR transaction payload after applying the signature -- **Response**: Updated transaction record with threshold status metadata; includes `txHash` when submission succeeds -- **Error Handling**: 400 (validation), 401 (auth), 403 (authorization), 404 (not found), 409 (duplicate/rejected), 502 (submission failure), 500 (server) + - `address`: Requester address (must match JWT payload) +- **Response**: Array of pending transaction objects with signature state details +- **Error Handling**: 400 (validation), 401 (auth), 403 (authorization), 404 (not found), 405 (method), 500 (server) #### `submitDatum.ts` - POST `/api/v1/submitDatum` @@ -80,6 +78,25 @@ A comprehensive REST API implementation for the multisig wallet application, pro - **Response**: Signable object with ID, signatures, and state - **Error Handling**: 400 (validation), 401 (auth/signature), 403 (authorization), 500 (server) +#### `signTransaction.ts` - POST `/api/v1/signTransaction` + +- **Purpose**: Record a signer witness for a pending multisig transaction and optionally submit it to the network +- **Authentication**: Required (JWT Bearer token) +- **Features**: + - Wallet ownership validation and enforcement of unique signer participation + - Native signature and public key verification against the transaction hash + - Witness aggregation with snapshot updates for CBOR and JSON representations + - Automatic network submission when the multisig threshold is met, with failure capture +- **Request Body**: + - `walletId`: Wallet identifier + - `transactionId`: Transaction identifier + - `address`: Signer address (must match JWT payload) + - `signature`: Witness signature in hex format + - `key`: Public key in hex format + - `broadcast`: Optional boolean to control automatic submission (defaults to true) +- **Response**: Updated transaction object with witness metadata, submission state, and transaction hash +- **Error Handling**: 400 (validation), 401 (signature), 403 (authorization), 404 (not found), 409 (state conflict), 502 (broadcast failure), 500 (server) + ### Wallet Management #### `walletIds.ts` - GET `/api/v1/walletIds` diff --git a/src/pages/api/v1/signTransaction.ts b/src/pages/api/v1/signTransaction.ts index 8838fa2e..db7ae47e 100644 --- a/src/pages/api/v1/signTransaction.ts +++ b/src/pages/api/v1/signTransaction.ts @@ -5,16 +5,35 @@ import { verifyJwt } from "@/lib/verifyJwt"; import { createCaller } from "@/server/api/root"; import { db } from "@/server/db"; import { getProvider } from "@/utils/get-provider"; +import { addressToNetwork } from "@/utils/multisigSDK"; +import { resolvePaymentKeyHash } from "@meshsdk/core"; +import { csl, calculateTxHash } from "@meshsdk/core-csl"; + +function coerceBoolean(value: unknown, fallback = false): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true") return true; + if (normalized === "false") return false; + } + return fallback; +} -const HEX_REGEX = /^[0-9a-fA-F]+$/; +function normalizeHex(value: string, context: string): string { + const trimmed = value.trim().toLowerCase().replace(/^0x/, ""); + if (trimmed.length === 0 || trimmed.length % 2 !== 0 || !/^[0-9a-f]+$/.test(trimmed)) { + throw new Error(`Invalid ${context} hex string`); + } + return trimmed; +} -const isHexString = (value: unknown): value is string => - typeof value === "string" && value.length > 0 && HEX_REGEX.test(value); +function toHex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("hex"); +} -const resolveNetworkFromAddress = (bech32Address: string): 0 | 1 => - bech32Address.startsWith("addr_test") || bech32Address.startsWith("stake_test") - ? 0 - : 1; +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} export default async function handler( req: NextApiRequest, @@ -32,7 +51,9 @@ export default async function handler( } const authHeader = req.headers.authorization; - const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; + const token = authHeader?.startsWith("Bearer ") + ? authHeader.slice(7) + : null; if (!token) { return res.status(401).json({ error: "Unauthorized - Missing token" }); @@ -48,35 +69,44 @@ export default async function handler( expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(), } as const; - const caller = createCaller({ db, session }); - - const body = - typeof req.body === "object" && req.body !== null - ? (req.body as Record) - : {}; - - const { walletId, transactionId, address, signedTx } = body; - - if (typeof walletId !== "string" || walletId.trim().length === 0) { + type SignTransactionRequestBody = { + walletId?: unknown; + transactionId?: unknown; + address?: unknown; + signature?: unknown; + key?: unknown; + broadcast?: unknown; + }; + + const { + walletId, + transactionId, + address, + signature, + key, + broadcast: rawBroadcast, + } = (req.body ?? {}) as SignTransactionRequestBody; + + if (typeof walletId !== "string" || walletId.trim() === "") { return res.status(400).json({ error: "Missing or invalid walletId" }); } - if (typeof transactionId !== "string" || transactionId.trim().length === 0) { + if (typeof transactionId !== "string" || transactionId.trim() === "") { return res .status(400) .json({ error: "Missing or invalid transactionId" }); } - if (typeof address !== "string" || address.trim().length === 0) { + if (typeof address !== "string" || address.trim() === "") { return res.status(400).json({ error: "Missing or invalid address" }); } - if (!isHexString(signedTx)) { - return res.status(400).json({ error: "Missing or invalid signedTx" }); + if (typeof signature !== "string" || signature.trim() === "") { + return res.status(400).json({ error: "Missing or invalid signature" }); } - if (signedTx.length % 2 !== 0) { - return res.status(400).json({ error: "Missing or invalid signedTx" }); + if (typeof key !== "string" || key.trim() === "") { + return res.status(400).json({ error: "Missing or invalid key" }); } if (payload.address !== address) { @@ -84,6 +114,8 @@ export default async function handler( } try { + const caller = createCaller({ db, session }); + const wallet = await caller.wallet.getWallet({ walletId, address }); if (!wallet) { return res.status(404).json({ error: "Wallet not found" }); @@ -112,74 +144,304 @@ export default async function handler( if (transaction.signedAddresses.includes(address)) { return res .status(409) - .json({ error: "Address already signed this transaction" }); + .json({ error: "Address has already signed this transaction" }); } if (transaction.rejectedAddresses.includes(address)) { return res .status(409) - .json({ error: "Address has rejected this transaction" }); - } - - const updatedSignedAddresses = [...transaction.signedAddresses, address]; - const updatedRejectedAddresses = [...transaction.rejectedAddresses]; - - const totalSigners = wallet.signersAddresses.length; - const requiredSigners = wallet.numRequiredSigners ?? undefined; - - let thresholdReached = false; - switch (wallet.type) { - case "any": - thresholdReached = true; - break; - case "all": - thresholdReached = updatedSignedAddresses.length >= totalSigners; - break; - case "atLeast": - thresholdReached = - typeof requiredSigners === "number" && - updatedSignedAddresses.length >= requiredSigners; - break; - default: - thresholdReached = false; - } - - let finalTxHash: string | undefined; - if (thresholdReached && !finalTxHash) { + .json({ error: "Address has already rejected this transaction" }); + } + + const updatedSignedAddresses = Array.from( + new Set([...transaction.signedAddresses, address]), + ); + + const storedTxHex = transaction.txCbor?.trim(); + if (!storedTxHex) { + return res.status(500).json({ error: "Stored transaction is missing txCbor" }); + } + + let parsedStoredTx: ReturnType; + try { + parsedStoredTx = csl.Transaction.from_hex(storedTxHex); + } catch (error: unknown) { + console.error("Failed to parse stored transaction", toError(error)); + return res.status(500).json({ error: "Invalid stored transaction data" }); + } + + const txBodyClone = csl.TransactionBody.from_bytes( + parsedStoredTx.body().to_bytes(), + ); + const witnessSetClone = csl.TransactionWitnessSet.from_bytes( + parsedStoredTx.witness_set().to_bytes(), + ); + + let vkeyWitnesses = witnessSetClone.vkeys(); + if (!vkeyWitnesses) { + vkeyWitnesses = csl.Vkeywitnesses.new(); + witnessSetClone.set_vkeys(vkeyWitnesses); + } else { + vkeyWitnesses = csl.Vkeywitnesses.from_bytes(vkeyWitnesses.to_bytes()); + witnessSetClone.set_vkeys(vkeyWitnesses); + } + + const signatureHex = normalizeHex(signature, "signature"); + const keyHex = normalizeHex(key, "key"); + + let witnessPublicKey: csl.PublicKey; + let witnessSignature: csl.Ed25519Signature; + let witnessToAdd: csl.Vkeywitness; + + try { + witnessPublicKey = csl.PublicKey.from_hex(keyHex); + witnessSignature = csl.Ed25519Signature.from_hex(signatureHex); + const vkey = csl.Vkey.new(witnessPublicKey); + witnessToAdd = csl.Vkeywitness.new(vkey, witnessSignature); + } catch (error: unknown) { + console.error("Invalid signature payload", toError(error)); + return res.status(400).json({ error: "Invalid signature payload" }); + } + + const witnessKeyHash = toHex(witnessPublicKey.hash().to_bytes()).toLowerCase(); + + let addressKeyHash: string; + try { + addressKeyHash = resolvePaymentKeyHash(address).toLowerCase(); + } catch (error: unknown) { + console.error("Unable to resolve payment key hash", toError(error)); + return res.status(400).json({ error: "Invalid address format" }); + } + + if (addressKeyHash !== witnessKeyHash) { + return res + .status(403) + .json({ error: "Signature public key does not match address" }); + } + + const txHashHex = calculateTxHash(parsedStoredTx.to_hex()).toLowerCase(); + const txHashBytes = Buffer.from(txHashHex, "hex"); + const isSignatureValid = witnessPublicKey.verify(txHashBytes, witnessSignature); + + if (!isSignatureValid) { + return res.status(401).json({ error: "Invalid signature for transaction" }); + } + + const existingWitnessCount = vkeyWitnesses.len(); + for (let i = 0; i < existingWitnessCount; i++) { + const existingWitness = vkeyWitnesses.get(i); + const existingKeyHash = toHex( + existingWitness.vkey().public_key().hash().to_bytes(), + ).toLowerCase(); + if (existingKeyHash === witnessKeyHash) { + return res + .status(409) + .json({ error: "Witness for this address already exists" }); + } + } + + vkeyWitnesses.add(witnessToAdd); + + const updatedTx = csl.Transaction.new( + txBodyClone, + witnessSetClone, + parsedStoredTx.auxiliary_data(), + ); + if (!parsedStoredTx.is_valid()) { + updatedTx.set_is_valid(false); + } + const txHexForUpdate = updatedTx.to_hex(); + + const witnessSummaries: { + keyHashHex: string; + publicKeyBech32: string; + signatureHex: string; + }[] = []; + const witnessSetForExport = csl.Vkeywitnesses.from_bytes( + vkeyWitnesses.to_bytes(), + ); + const witnessCountForExport = witnessSetForExport.len(); + for (let i = 0; i < witnessCountForExport; i++) { + const witness = witnessSetForExport.get(i); + witnessSummaries.push({ + keyHashHex: toHex( + witness.vkey().public_key().hash().to_bytes(), + ).toLowerCase(), + publicKeyBech32: witness.vkey().public_key().to_bech32(), + signatureHex: toHex(witness.signature().to_bytes()).toLowerCase(), + }); + } + + const shouldAttemptBroadcast = coerceBoolean(rawBroadcast, true); + + const threshold = (() => { + switch (wallet.type) { + case "atLeast": + return wallet.numRequiredSigners ?? wallet.signersAddresses.length; + case "all": + return wallet.signersAddresses.length; + case "any": + return 1; + default: + return wallet.numRequiredSigners ?? 1; + } + })(); + + let nextState = transaction.state; + let finalTxHash = transaction.txHash ?? undefined; + let submissionError: string | undefined; + + const resolveNetworkId = (): number => { + const candidateAddresses = Array.isArray(wallet.signersAddresses) + ? wallet.signersAddresses + : []; + + for (const candidate of candidateAddresses) { + if (typeof candidate !== "string") { + continue; + } + const trimmed = candidate.trim(); + if (!trimmed) { + continue; + } + try { + return addressToNetwork(trimmed); + } catch (error: unknown) { + console.warn("Unable to resolve network from wallet signer address", { + walletId, + candidate: trimmed, + error: toError(error), + }); + } + } + + throw new Error("Unable to determine network from wallet data"); + }; + + if ( + shouldAttemptBroadcast && + threshold > 0 && + updatedSignedAddresses.length >= threshold + ) { try { - const network = resolveNetworkFromAddress(address); - const blockchainProvider = getProvider(network); - finalTxHash = await blockchainProvider.submitTx(signedTx); - } catch (submitError) { - console.error("Failed to submit transaction", { - message: (submitError as Error)?.message, - stack: (submitError as Error)?.stack, + const network = resolveNetworkId(); + const provider = getProvider(network); + const submittedHash = await provider.submitTx(txHexForUpdate); + finalTxHash = submittedHash; + nextState = 1; + } catch (error: unknown) { + const err = toError(error); + console.error("Error submitting signed transaction", { + transactionId, + error: err, }); - return res.status(502).json({ error: "Failed to submit transaction" }); + submissionError = err.message ?? "Failed to submit transaction"; } } - const nextState = finalTxHash ? 1 : 0; + if (transaction.state === 1) { + nextState = 1; + } else if (nextState !== 1) { + nextState = 0; + } + + let txJsonForUpdate = transaction.txJson; + try { + const parsedTxJson = JSON.parse( + transaction.txJson, + ) as Record; + const enrichedTxJson = { + ...parsedTxJson, + multisig: { + state: nextState, + submitted: nextState === 1, + signedAddresses: updatedSignedAddresses, + rejectedAddresses: transaction.rejectedAddresses, + witnesses: witnessSummaries, + txHash: (finalTxHash ?? txHashHex).toLowerCase(), + bodyHash: txHashHex, + submissionError: submissionError ?? null, + }, + }; + txJsonForUpdate = JSON.stringify(enrichedTxJson); + } catch (error: unknown) { + const err = toError(error); + console.warn("Unable to update txJson snapshot", { + transactionId, + error: err, + }); + } - const updatedTransaction = await caller.transaction.updateTransaction({ - transactionId, - txCbor: signedTx, - signedAddresses: updatedSignedAddresses, - rejectedAddresses: updatedRejectedAddresses, + const updateData: { + signedAddresses: { set: string[] }; + rejectedAddresses: { set: string[] }; + txCbor: string; + txJson: string; + state: number; + txHash?: string; + } = { + signedAddresses: { set: updatedSignedAddresses }, + rejectedAddresses: { set: transaction.rejectedAddresses }, + txCbor: txHexForUpdate, + txJson: txJsonForUpdate, state: nextState, - txHash: finalTxHash, + }; + + if (finalTxHash) { + updateData.txHash = finalTxHash; + } + + const updateResult = await db.transaction.updateMany({ + where: { + id: transactionId, + signedAddresses: { equals: transaction.signedAddresses }, + rejectedAddresses: { equals: transaction.rejectedAddresses }, + txCbor: transaction.txCbor ?? "", + txJson: transaction.txJson, + }, + data: updateData, }); + if (updateResult.count === 0) { + const latest = await db.transaction.findUnique({ + where: { id: transactionId }, + }); + + return res.status(409).json({ + error: "Transaction was updated by another signer. Please refresh and try again.", + ...(latest ? { transaction: latest } : {}), + }); + } + + const updatedTransaction = await db.transaction.findUnique({ + where: { id: transactionId }, + }); + + if (!updatedTransaction) { + return res.status(500).json({ error: "Failed to load updated transaction state" }); + } + + if (submissionError) { + return res.status(502).json({ + error: "Transaction witness recorded, but submission to network failed", + transaction: updatedTransaction, + submitted: false, + txHash: finalTxHash, + submissionError, + }); + } + return res.status(200).json({ transaction: updatedTransaction, - thresholdReached, + submitted: nextState === 1, + txHash: finalTxHash, }); - } catch (error) { + } catch (error: unknown) { + const err = toError(error); console.error("Error in signTransaction handler", { - message: (error as Error)?.message, - stack: (error as Error)?.stack, + message: err.message, + stack: err.stack, }); return res.status(500).json({ error: "Internal Server Error" }); } } - diff --git a/src/utils/swagger.ts b/src/utils/swagger.ts index 72c56fdc..7feebd3b 100644 --- a/src/utils/swagger.ts +++ b/src/utils/swagger.ts @@ -196,12 +196,75 @@ export const swaggerSpec = swaggerJSDoc({ }, }, }, + "/api/v1/pendingTransactions": { + get: { + tags: ["V1"], + summary: "Get pending transactions for a wallet", + description: + "Returns all pending multisig transactions awaiting signatures for the specified wallet and address.", + parameters: [ + { + in: "query", + name: "walletId", + required: true, + schema: { type: "string" }, + description: "ID of the multisig wallet", + }, + { + in: "query", + name: "address", + required: true, + schema: { type: "string" }, + description: "Address associated with the wallet (must match JWT)", + }, + ], + responses: { + 200: { + description: "A list of pending transactions", + content: { + "application/json": { + schema: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + walletId: { type: "string" }, + txJson: { type: "string" }, + txCbor: { type: "string" }, + signedAddresses: { + type: "array", + items: { type: "string" }, + }, + rejectedAddresses: { + type: "array", + items: { type: "string" }, + }, + description: { type: "string" }, + state: { type: "number" }, + createdAt: { type: "string" }, + updatedAt: { type: "string" }, + }, + }, + }, + }, + }, + }, + 400: { description: "Invalid address or walletId parameter" }, + 401: { description: "Unauthorized or invalid token" }, + 403: { description: "Address mismatch" }, + 404: { description: "Wallet not found" }, + 405: { description: "Method not allowed" }, + 500: { description: "Internal server error" }, + }, + }, + }, "/api/v1/signTransaction": { post: { tags: ["V1"], - summary: "Sign a pending multisig transaction", + summary: "Sign an existing transaction", description: - "Adds a signature to an existing multisig transaction and automatically submits it once the required signatures are met.", + "Records a witness for an existing multisig transaction and optionally submits it if the signing threshold is met.", requestBody: { required: true, content: { @@ -212,13 +275,16 @@ export const swaggerSpec = swaggerJSDoc({ walletId: { type: "string" }, transactionId: { type: "string" }, address: { type: "string" }, - signedTx: { type: "string" }, + signature: { type: "string" }, + key: { type: "string" }, + broadcast: { type: "boolean" }, }, required: [ "walletId", "transactionId", "address", - "signedTx", + "signature", + "key", ], }, }, @@ -226,7 +292,8 @@ export const swaggerSpec = swaggerJSDoc({ }, responses: { 200: { - description: "Transaction successfully updated", + description: + "Witness stored. Includes updated transaction and submission status.", content: { "application/json": { schema: { @@ -254,18 +321,59 @@ export const swaggerSpec = swaggerJSDoc({ updatedAt: { type: "string" }, }, }, - thresholdReached: { type: "boolean" }, + submitted: { type: "boolean" }, + txHash: { type: "string", nullable: true }, }, }, }, }, }, - 400: { description: "Validation error" }, - 401: { description: "Unauthorized" }, - 403: { description: "Authorization error" }, + 401: { description: "Unauthorized or invalid signature" }, + 403: { description: "Forbidden due to address mismatch or access" }, 404: { description: "Wallet or transaction not found" }, - 409: { description: "Signer already processed this transaction" }, - 502: { description: "Blockchain submission failed" }, + 409: { + description: + "Transaction already finalized or conflicting update detected", + }, + 502: { + description: + "Witness stored but submission to the network failed", + content: { + "application/json": { + schema: { + type: "object", + properties: { + error: { type: "string" }, + transaction: { + type: "object", + properties: { + id: { type: "string" }, + walletId: { type: "string" }, + txJson: { type: "string" }, + txCbor: { type: "string" }, + signedAddresses: { + type: "array", + items: { type: "string" }, + }, + rejectedAddresses: { + type: "array", + items: { type: "string" }, + }, + description: { type: "string" }, + state: { type: "number" }, + txHash: { type: "string" }, + createdAt: { type: "string" }, + updatedAt: { type: "string" }, + }, + }, + submitted: { type: "boolean" }, + txHash: { type: "string", nullable: true }, + submissionError: { type: "string" }, + }, + }, + }, + }, + }, 405: { description: "Method not allowed" }, 500: { description: "Internal server error" }, },