Skip to content
Closed
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
2 changes: 2 additions & 0 deletions packages/arc-adapter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export * from './money.js';
export * from './redaction.js';
export * from './config.js';
export * from './readiness.js';
export * from './receipt.js';
export * from './outcome.js';
133 changes: 133 additions & 0 deletions packages/arc-adapter/src/outcome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/**
* Submission outcome classifier (B02.5).
*
* Maps a provider or RPC response to one of the three SettlementPort results
* in `milestones/CONTRACTS.md` section 5. Pure: no network, no durable state,
* no clock.
*
* The whole module exists to make one bias structural: **doubt means
* `POSSIBLY_SUBMITTED`**. `DEFINITELY_NOT_SUBMITTED` is a strong claim that no
* external effect occurred, and it is the only result that permits a fresh
* attempt without reconciliation. It is therefore granted only for narrow,
* documented proofs, and every unrecognized shape falls through to
* `POSSIBLY_SUBMITTED`.
*
* `.agent/SECURITY_INVARIANTS.md`: a timeout, crash, disconnect, lost response,
* or provider error after possible submission creates `UNKNOWN`.
*/

export type SubmissionOutcome =
/** Verified final receipt and exactly matching Transfer evidence. */
| 'CONFIRMED'
/** Narrow documented proof that no broadcast or external effect occurred. */
| 'DEFINITELY_NOT_SUBMITTED'
/** Any doubt at all. Maps to durable UNKNOWN and requires reconciliation. */
| 'POSSIBLY_SUBMITTED';

/**
* Failure shapes that prove the request never reached the network.
*
* Each is a pre-flight rejection: the provider refused the request before
* broadcasting anything, so no transaction can exist. Adding to this list
* widens the set of situations that permit a retry, so entries need real
* documented proof.
*/
export type PreSubmissionProof =
/** Privy policy denied the action. Nothing was signed. */
| 'POLICY_DENIED'
/** Request failed schema validation at the provider before signing. */
| 'REQUEST_VALIDATION_FAILED'
/** Local scope check refused the transaction before it was ever sent. */
| 'LOCAL_SCOPE_DENIED'
/** Authorization was rejected as expired or invalid before signing. */
| 'AUTHORIZATION_INVALID';

/**
* Ambiguous shapes. Listed for documentation and exhaustiveness; every one of
* them classifies as `POSSIBLY_SUBMITTED`.
*/
export type AmbiguousSignal =
| 'TIMEOUT'
| 'CONNECTION_RESET'
| 'LOST_RESPONSE'
| 'TRUNCATED_RESPONSE'
| 'MALFORMED_RESPONSE'
| 'PROVIDER_5XX'
| 'RATE_LIMITED'
| 'PROCESS_CRASH'
| 'UNKNOWN_ERROR';

export type ProviderResponse =
/** A receipt was obtained and independently verified as confirmed. */
| { readonly kind: 'VERIFIED_RECEIPT'; readonly confirmed: boolean }
/** The provider proved it never submitted. */
| { readonly kind: 'PRE_SUBMISSION_FAILURE'; readonly proof: PreSubmissionProof }
/** Something went wrong and we cannot prove what. */
| { readonly kind: 'AMBIGUOUS'; readonly signal: AmbiguousSignal }
/** A shape this build does not recognize. */
| { readonly kind: 'UNRECOGNIZED'; readonly detail: string };

export interface Classification {
readonly outcome: SubmissionOutcome;
/** Sanitized explanation suitable for an operator timeline. */
readonly reason: string;
}

/**
* Classify a provider response.
*
* Note the asymmetry in the `VERIFIED_RECEIPT` case: a confirmed receipt gives
* `CONFIRMED`, but an unconfirmed one does NOT give
* `DEFINITELY_NOT_SUBMITTED`. Failing to prove a settlement happened is not
* proof that it did not; the transaction may be pending, or the receipt may be
* for an attempt whose Transfer we could not match yet.
*/
export function classifyOutcome(response: ProviderResponse): Classification {
switch (response.kind) {
case 'VERIFIED_RECEIPT':
return response.confirmed
? {
outcome: 'CONFIRMED',
reason: 'Final receipt and exactly the expected Transfer were verified.',
}
: {
outcome: 'POSSIBLY_SUBMITTED',
reason:
'A receipt was obtained but did not prove the expected settlement. ' +
'Absence of proof is not proof of absence; reconcile before retrying.',
};

case 'PRE_SUBMISSION_FAILURE':
return {
outcome: 'DEFINITELY_NOT_SUBMITTED',
reason: `The request was refused before broadcast (${response.proof}); no external effect occurred.`,
};

case 'AMBIGUOUS':
return {
outcome: 'POSSIBLY_SUBMITTED',
reason: `The outcome is unknown after ${response.signal}; the transaction may have been broadcast.`,
};

case 'UNRECOGNIZED':
// An unrecognized response must never widen retry permission. This is
// the fail-closed default the module exists for.
return {
outcome: 'POSSIBLY_SUBMITTED',
reason: 'The provider response was not recognized, so submission cannot be ruled out.',
};

default: {
const unreachable: never = response;
return {
outcome: 'POSSIBLY_SUBMITTED',
reason: `Unhandled response shape: ${String(unreachable)}`,
};
}
}
}

/** Only this outcome permits a fresh attempt without reconciliation. */
export function permitsImmediateRetry(outcome: SubmissionOutcome): boolean {
return outcome === 'DEFINITELY_NOT_SUBMITTED';
}
159 changes: 159 additions & 0 deletions packages/arc-adapter/src/receipt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Arc receipt verification (B02.4).
*
* Confirms a settlement only from an exact final receipt plus exactly the
* expected ERC-20 Transfer log.
*
* The rule that matters: `status: 1` alone is NOT confirmation. A transaction
* can succeed while transferring nothing we asked for, or while emitting a
* Transfer to somewhere else. Confirmation requires the receipt to prove the
* specific movement of the specific amount to the specific recipient.
*
* `milestones/CONTRACTS.md`: "Arc receipt plus expected ERC-20 Transfer
* evidence establishes committed settlement."
*/

/** keccak256("Transfer(address,address,uint256)"). */
export const TRANSFER_EVENT_TOPIC =
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';

export interface ReceiptLog {
readonly address: string;
/** topic0 is the event signature; topic1/topic2 are indexed from/to. */
readonly topics: readonly string[];
/** ABI-encoded non-indexed data. For Transfer this is the amount word. */
readonly data: string;
readonly logIndex: number;
}

export interface TransactionReceipt {
readonly transactionHash: string;
readonly chainId: number;
/** The wallet that sent the transaction. */
readonly from: string;
/** The contract called. For a direct transfer this is the token. */
readonly to: string;
/** 1 success, 0 revert. */
readonly status: 0 | 1;
readonly blockNumber: bigint;
readonly blockHash: string;
readonly logs: readonly ReceiptLog[];
}

/** Exactly what the receipt must prove. */
export interface ExpectedSettlement {
readonly chainId: number;
readonly walletAddress: string;
readonly tokenContract: string;
readonly recipient: string;
readonly amountAtomic: bigint;
}

export type ReceiptVerdict =
/** Final receipt and exactly the expected Transfer. Terminal success. */
| { readonly result: 'CONFIRMED'; readonly transferLogIndex: number }
/** Final revert. Terminal failure; no value moved. */
| { readonly result: 'FINAL_REVERT'; readonly detail: string }
/**
* The receipt exists but does not prove the expected settlement. Never
* treated as failure: something happened on-chain and it must be reconciled.
*/
| { readonly result: 'NOT_CONFIRMED'; readonly detail: string };

function sameAddress(left: string, right: string): boolean {
return left.trim().toLowerCase() === right.trim().toLowerCase();
}

/** Decode a 32-byte address topic into an address. */
function addressFromTopic(topic: string): string {
return `0x${topic.slice(-40)}`.toLowerCase();
}

/** Decode a 32-byte word as an unsigned integer. */
function amountFromData(data: string): bigint | undefined {
const normalized = data.trim().toLowerCase();
if (!/^0x[0-9a-f]{64}$/.test(normalized)) return undefined;
return BigInt(normalized);
}

/**
* Verify a receipt against the expected settlement.
*
* Every identity is checked before the logs are inspected, so a receipt for a
* different chain, wallet, or transaction can never be matched by log content
* alone.
*/
export function verifyReceipt(
receipt: TransactionReceipt,
expected: ExpectedSettlement,
): ReceiptVerdict {
if (receipt.chainId !== expected.chainId) {
return {
result: 'NOT_CONFIRMED',
detail: `Receipt is from chain ${receipt.chainId}, expected ${expected.chainId}.`,
};
}

if (!sameAddress(receipt.from, expected.walletAddress)) {
return {
result: 'NOT_CONFIRMED',
detail: 'Receipt was not sent by the configured execution wallet.',
};
}

if (receipt.status === 0) {
// A revert moved no value. This is the one case that is safely terminal
// and permits the policy to schedule a fresh attempt.
return { result: 'FINAL_REVERT', detail: 'Transaction reverted; no value moved.' };
}

if (!sameAddress(receipt.to, expected.tokenContract)) {
return {
result: 'NOT_CONFIRMED',
detail: 'Receipt did not call the configured USDC contract.',
};
}

// Only Transfer logs emitted by the configured token count. A Transfer from
// some other contract proves nothing about our USDC balance.
const candidates = receipt.logs.filter(
(log) =>
sameAddress(log.address, expected.tokenContract) &&
log.topics[0]?.toLowerCase() === TRANSFER_EVENT_TOPIC,
);

const matches = candidates.filter((log) => {
const from = log.topics[1];
const to = log.topics[2];
if (from === undefined || to === undefined) return false;
if (!sameAddress(addressFromTopic(from), expected.walletAddress)) return false;
if (!sameAddress(addressFromTopic(to), expected.recipient)) return false;
return amountFromData(log.data) === expected.amountAtomic;
});

if (matches.length === 0) {
// Success status with no matching Transfer is explicitly NOT confirmation.
return {
result: 'NOT_CONFIRMED',
detail:
'Receipt status is success but it contains no Transfer matching the ' +
'expected sender, recipient, and amount.',
};
}

if (matches.length > 1) {
// Two identical transfers in one transaction means more value moved than
// the obligation authorized. Refusing to confirm forces reconciliation.
return {
result: 'NOT_CONFIRMED',
detail: `Receipt contains ${matches.length} matching Transfer logs; expected exactly one.`,
};
}

const match = matches[0];
if (match === undefined) {
return { result: 'NOT_CONFIRMED', detail: 'Matching Transfer log could not be read.' };
}

return { result: 'CONFIRMED', transferLogIndex: match.logIndex };
}
84 changes: 84 additions & 0 deletions packages/arc-adapter/test/outcome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from 'vitest';
import {
classifyOutcome,
permitsImmediateRetry,
type AmbiguousSignal,
type PreSubmissionProof,
type ProviderResponse,
} from '../src/outcome.js';

describe('confirmed', () => {
it('classifies a verified receipt as CONFIRMED', () => {
expect(classifyOutcome({ kind: 'VERIFIED_RECEIPT', confirmed: true }).outcome).toBe(
'CONFIRMED',
);
});

it('does not treat an unconfirmed receipt as proof of non-submission', () => {
// Absence of proof is not proof of absence. This must be
// POSSIBLY_SUBMITTED, never DEFINITELY_NOT_SUBMITTED.
expect(classifyOutcome({ kind: 'VERIFIED_RECEIPT', confirmed: false }).outcome).toBe(
'POSSIBLY_SUBMITTED',
);
});
});

describe('definitely not submitted', () => {
it.each<PreSubmissionProof>([
'POLICY_DENIED',
'REQUEST_VALIDATION_FAILED',
'LOCAL_SCOPE_DENIED',
'AUTHORIZATION_INVALID',
])('grants DEFINITELY_NOT_SUBMITTED for pre-flight proof %s', (proof) => {
expect(classifyOutcome({ kind: 'PRE_SUBMISSION_FAILURE', proof }).outcome).toBe(
'DEFINITELY_NOT_SUBMITTED',
);
});

it('is the only outcome permitting an immediate retry', () => {
expect(permitsImmediateRetry('DEFINITELY_NOT_SUBMITTED')).toBe(true);
expect(permitsImmediateRetry('POSSIBLY_SUBMITTED')).toBe(false);
expect(permitsImmediateRetry('CONFIRMED')).toBe(false);
});
});

describe('every ambiguous signal fails closed', () => {
it.each<AmbiguousSignal>([
'TIMEOUT',
'CONNECTION_RESET',
'LOST_RESPONSE',
'TRUNCATED_RESPONSE',
'MALFORMED_RESPONSE',
'PROVIDER_5XX',
'RATE_LIMITED',
'PROCESS_CRASH',
'UNKNOWN_ERROR',
])('classifies %s as POSSIBLY_SUBMITTED', (signal) => {
expect(classifyOutcome({ kind: 'AMBIGUOUS', signal }).outcome).toBe('POSSIBLY_SUBMITTED');
});

it('never lets an ambiguous signal permit a retry', () => {
const signals: AmbiguousSignal[] = ['TIMEOUT', 'LOST_RESPONSE', 'PROCESS_CRASH'];
for (const signal of signals) {
const { outcome } = classifyOutcome({ kind: 'AMBIGUOUS', signal });
expect(permitsImmediateRetry(outcome)).toBe(false);
}
});
});

describe('unrecognized responses', () => {
it('classifies an unrecognized shape as POSSIBLY_SUBMITTED', () => {
expect(classifyOutcome({ kind: 'UNRECOGNIZED', detail: 'new provider field' }).outcome).toBe(
'POSSIBLY_SUBMITTED',
);
});

it('fails closed on a response shape from the future', () => {
// Simulates a provider adding a response kind this build predates. It must
// not widen retry permission.
const fromTheFuture = { kind: 'SOMETHING_NEW' } as unknown as ProviderResponse;
const { outcome } = classifyOutcome(fromTheFuture);
expect(outcome).toBe('POSSIBLY_SUBMITTED');
expect(permitsImmediateRetry(outcome)).toBe(false);
});
});
Loading
Loading