diff --git a/README.md b/README.md index 89dccb4..eda95f3 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ Returns everything a signer needs to review before signing: "createdAt": "2026-08-03T...Z", "expiresAt": "2026-08-10T...Z", "submittedAt": null, + "submissionHash": null, // the actual on-chain hash once submitted; null until then "summary": { // fully decoded — never "some operations" "source": "G...", "fee": "100", diff --git a/migrations/002_add_submission_hash.sql b/migrations/002_add_submission_hash.sql new file mode 100644 index 0000000..feb8352 --- /dev/null +++ b/migrations/002_add_submission_hash.sql @@ -0,0 +1,6 @@ +-- The actual on-chain transaction hash returned by Horizon on successful +-- submission. This is distinct from pending_requests.tx_hash, which is the +-- signature-base hash of the UNSIGNED envelope (computed at creation time, +-- used to verify signatures) and is never the submitted transaction's hash. +-- NULL until a submission actually succeeds. +ALTER TABLE pending_requests ADD COLUMN IF NOT EXISTS submission_hash TEXT; diff --git a/src/fetch.ts b/src/fetch.ts index a3a7073..06ca350 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -48,6 +48,7 @@ export async function handleFetch( createdAt: row.createdAt, expiresAt: row.expiresAt, submittedAt: row.submittedAt, + submissionHash: row.submissionHash, summary: describeTransaction(transaction), }; diff --git a/src/store.ts b/src/store.ts index abd7fe4..d313ff0 100644 --- a/src/store.ts +++ b/src/store.ts @@ -25,6 +25,8 @@ export interface PendingRequestRow { createdAt: string; expiresAt: string; submittedAt: string | null; + /** The actual on-chain hash returned by Horizon on successful submission; null until then. */ + submissionHash: string | null; } export interface CreateRequestInput { @@ -83,6 +85,7 @@ interface RequestRowShape { created_at: Date; expires_at: Date; submitted_at: Date | null; + submission_hash: string | null; } interface SignatureRowShape { @@ -106,6 +109,7 @@ function mapRequest(row: RequestRowShape): PendingRequestRow { createdAt: row.created_at.toISOString(), expiresAt: row.expires_at.toISOString(), submittedAt: row.submitted_at?.toISOString() ?? null, + submissionHash: row.submission_hash, }; } @@ -312,6 +316,15 @@ export class Store { return (result.rowCount ?? 0) > 0; } + /** + * Records the actual on-chain hash returned by Horizon after a successful + * submission. Must only be called once the network has confirmed the + * submission succeeded; never call this speculatively. + */ + async recordSubmissionSuccess(id: string, submissionHash: string): Promise { + await this.pool.query(`UPDATE pending_requests SET submission_hash = $2 WHERE id = $1`, [id, submissionHash]); + } + async recordSubmitFailure(id: string, errorMessage: string, attempts: number): Promise { await this.pool.query( `UPDATE pending_requests diff --git a/src/submit.ts b/src/submit.ts index 7f7ce24..0f17201 100644 --- a/src/submit.ts +++ b/src/submit.ts @@ -37,14 +37,21 @@ export interface SubmissionAttemptResult { hash: string; } -/** Assembles the fully-signed envelope and submits it to the network. */ +/** + * Assembles the fully-signed envelope, submits it to the network, and + * persists the actual Horizon-returned hash once submission succeeds. This is + * the only place a submission hash is ever recorded — never before Horizon + * confirms success, and never derived locally from the envelope. + */ export async function submitSignedRequest( deps: AppDeps, row: PendingRequestRow, signatures: readonly StoredSignature[], ): Promise { const envelope = assembleSignedEnvelope(row.transactionXdr, row.network, signatures); - return deps.submissionGateway.submitTransaction(envelope, row.network); + const result = await deps.submissionGateway.submitTransaction(envelope, row.network); + await deps.store.recordSubmissionSuccess(row.id, result.hash); + return result; } /** diff --git a/test/submit.test.ts b/test/submit.test.ts index 62a441d..0ba5a58 100644 --- a/test/submit.test.ts +++ b/test/submit.test.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { afterAll, afterEach, describe, expect, it } from 'vitest'; import { retrySubmittableRequests } from '../src/submit.js'; import { parseTransaction } from '../src/transaction.js'; @@ -146,3 +147,69 @@ describe('threshold-met auto-submission', () => { expect(ctx.submissionGateway.submissions).toHaveLength(0); }); }); + +describe('submission hash persistence', () => { + it('persists the exact hash Horizon returned, distinct from the unsigned-envelope tx_hash, and exposes it via fetch', async () => { + const { requestId, signers, tx } = await setupWithRequest([1], 1); + + const beforeRow = await ctx.store.getRequest(requestId); + expect(beforeRow!.submissionHash).toBeNull(); + + await sign(requestId, signers[0]!, tx); + + expect(ctx.submissionGateway.submissions).toHaveLength(1); + const expectedHash = createHash('sha256') + .update(ctx.submissionGateway.submissions[0]!.xdr) + .digest('hex'); + const row = await ctx.store.getRequest(requestId); + expect(row!.status).toBe('submitted'); + expect(row!.submissionHash).toBe(expectedHash); + // The stored submission hash must never equal the unsigned-envelope tx_hash + // (a different value, computed before any signature existed). + expect(row!.submissionHash).not.toBe(row!.txHash); + + const fetched = await getJson(ctx.app, `/requests/${requestId}`); + expect(fetched.body.submissionHash).toBe(expectedHash); + }); + + it('does not persist a submission hash when submission fails', async () => { + const { requestId, signers, tx } = await setupWithRequest([1], 1); + ctx.submissionGateway.failWith(new Error('horizon is down')); + + const status = await sign(requestId, signers[0]!, tx); + expect(status).toBe(200); + + const row = await ctx.store.getRequest(requestId); + expect(row!.status).toBe('pending'); + expect(row!.submissionHash).toBeNull(); + + const fetched = await getJson(ctx.app, `/requests/${requestId}`); + expect(fetched.body.submissionHash).toBeNull(); + }); + + it('persists the submission hash on a background-job retry success, not just the first-attempt path', async () => { + const { requestId, signers, tx } = await setupWithRequest([1, 1], 2); + + await sign(requestId, signers[0]!, tx); + ctx.submissionGateway.failWith(new Error('horizon is down')); + await sign(requestId, signers[1]!, tx); + + const afterFailure = await ctx.store.getRequest(requestId); + expect(afterFailure!.submissionHash).toBeNull(); + + await retrySubmittableRequests( + { + config: ctx.config, + store: ctx.store, + accountGateway: ctx.accountGateway, + submissionGateway: ctx.submissionGateway, + }, + noopLogger, + ); + + const afterRetry = await ctx.store.getRequest(requestId); + expect(afterRetry!.status).toBe('submitted'); + expect(afterRetry!.submissionHash).not.toBeNull(); + expect(afterRetry!.submissionHash).not.toBe(afterRetry!.txHash); + }); +});