Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions migrations/002_add_submission_hash.sql
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export async function handleFetch(
createdAt: row.createdAt,
expiresAt: row.expiresAt,
submittedAt: row.submittedAt,
submissionHash: row.submissionHash,
summary: describeTransaction(transaction),
};

Expand Down
13 changes: 13 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -83,6 +85,7 @@ interface RequestRowShape {
created_at: Date;
expires_at: Date;
submitted_at: Date | null;
submission_hash: string | null;
}

interface SignatureRowShape {
Expand All @@ -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,
};
}

Expand Down Expand Up @@ -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<void> {
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<void> {
await this.pool.query(
`UPDATE pending_requests
Expand Down
11 changes: 9 additions & 2 deletions src/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SubmissionAttemptResult> {
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;
}

/**
Expand Down
67 changes: 67 additions & 0 deletions test/submit.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
});
});
Loading