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
69 changes: 69 additions & 0 deletions app-lib/contractErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Buffer } from "buffer"
import { describe, expect, it, vi } from "vitest"
import { errorName, errorNames } from "./contractErrors"
import { generatedSpecs } from "./contracts"

const errorCase = (name: string, value: number) => ({
name: () => Buffer.from(name),
value: () => value,
})

const spec = (...cases: ReturnType<typeof errorCase>[]) => ({
errorCases: () => cases,
})

describe("errorNames", () => {
it("merges the error cases of every spec by code", () => {
const names = errorNames([
spec(errorCase("InvalidAmount", 6007), errorCase("Paused", 1000)),
spec(errorCase("NoRecord", 3006), errorCase("Paused", 1000)),
])

expect(names.get(6007)).toBe("InvalidAmount")
expect(names.get(3006)).toBe("NoRecord")
expect(names.get(1000)).toBe("Paused")
})

it("knows no name for a code no spec declares", () => {
expect(errorNames([spec(errorCase("NoRecord", 3006))]).get(9999)).toBe(
undefined,
)
})

it("leaves a code two specs name differently unresolved and logs it", () => {
const logged = vi.spyOn(console, "error").mockImplementation(() => {})
try {
const names = errorNames([
spec(errorCase("WindDownActive", 6046), errorCase("NoRecord", 3006)),
spec(errorCase("AlreadyPriced", 6046), errorCase("Paused", 1000)),
])

expect(names.get(6046)).toBe(undefined)
expect(names.get(3006)).toBe("NoRecord")
expect(names.get(1000)).toBe("Paused")
expect(logged).toHaveBeenCalledOnce()
expect(logged.mock.calls[0]?.[0]).toMatch(
/#6046.*WindDownActive.*AlreadyPriced.*regenerate the clients from the same contracts/,
)
} finally {
logged.mockRestore()
}
})
})

describe("errorName", () => {
it("names every error case of the generated clients, with no code named twice", () => {
const logged = vi.spyOn(console, "error").mockImplementation(() => {})
try {
const cases = generatedSpecs().flatMap((spec) => spec.errorCases())

expect(cases.length).toBeGreaterThan(0)
for (const errorCase of cases) {
expect(errorName(errorCase.value())).toBe(errorCase.name().toString())
}
expect(logged).not.toHaveBeenCalled()
} finally {
logged.mockRestore()
}
})
})
40 changes: 40 additions & 0 deletions app-lib/contractErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { generatedSpecs } from "./contracts"

interface ErrorCase {
name(): { toString(): string }
value(): number
}

export interface ErrorSpec {
errorCases(): ErrorCase[]
}

export function errorNames(specs: ErrorSpec[]): Map<number, string> {
const names = new Map<number, string>()
const conflicting = new Set<number>()
for (const spec of specs) {
for (const errorCase of spec.errorCases()) {
const code = errorCase.value()
if (conflicting.has(code)) continue
const name = errorCase.name().toString()
const known = names.get(code)
if (known !== undefined && known !== name) {
console.error(
`Contract error #${code} is ${known} in one generated client and ${name} in another; regenerate the clients from the same contracts`,
)
conflicting.add(code)
names.delete(code)
continue
}
names.set(code, name)
}
}
return names
}

let generatedNames: Map<number, string> | undefined

export function errorName(code: number): string | undefined {
generatedNames ??= errorNames(generatedSpecs())
return generatedNames.get(code)
}
14 changes: 14 additions & 0 deletions app-lib/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type VaultPendingChange,
type WindDownInfo,
} from "./clients/async_vault/src/index"
import { Client as ComplianceApi } from "./clients/compliance/src/index"
import { Client as IdentityVerifierApi } from "./clients/identity_verifier/src/index"
import {
Client as NavOracleApi,
Expand Down Expand Up @@ -80,6 +81,19 @@ export const connectAsyncVault = async (
): Promise<AsyncVaultApi> =>
new AsyncVaultApi(clientOptions(contractId, signer, options))

// The spec is embedded in the generated client; building one calls no contract.
export const generatedSpecs = () => {
const options = clientOptions("")
return [
new AssetApi(options).spec,
new AsyncVaultApi(options).spec,
new ComplianceApi(options).spec,
new IdentityVerifierApi(options).spec,
new NavOracleApi(options).spec,
new ShareTokenApi(options).spec,
]
}

export const isUserRejection = (error: unknown): boolean =>
error instanceof AssembledTransaction.Errors.UserRejected

Expand Down
25 changes: 19 additions & 6 deletions app-lib/multisig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ import {
} from "@stellar/stellar-sdk/contract"
import { Api } from "@stellar/stellar-sdk/rpc"
import { beforeEach, describe, expect, it, vi } from "vitest"
import {
contractErrorCode,
simulationError,
} from "../app/src/test/contractErrorCode"
Comment on lines +26 to +29
import type { AccountSigners } from "./accounts"
import { RWAError } from "./clients/share_token/src/index"
import { connectAsyncVault } from "./contracts"
import { networkPassphrase } from "./env"
import {
Expand Down Expand Up @@ -766,12 +771,12 @@ const simulated = (): Api.SimulateTransactionSuccessResponse => ({
result: { auth: [], retval: xdr.ScVal.scvVoid() },
})

const refused = (code: number): Api.SimulateTransactionErrorResponse => ({
const refused = (name: string): Api.SimulateTransactionErrorResponse => ({
id: "1",
latestLedger: 1_000,
events: [],
_parsed: true,
error: `HostError: Error(Contract, #${code})`,
error: simulationError(RWAError, name),
})

const adminWallet = vi.fn(async (preimage: string) => ({
Expand Down Expand Up @@ -815,12 +820,16 @@ describe("authorizePending", () => {
})

it("reports the contract's refusal from the re-simulation", async () => {
simulateMock.mockResolvedValue(refused(300))
simulateMock.mockResolvedValue(refused("InsufficientBalance"))
const encoded = legalTransfer()

await expect(
authorizePending(assembled(encoded), decodePending(encoded), adminWallet),
).resolves.toEqual({ kind: "refused", code: 300 })
).resolves.toEqual({
kind: "refused",
code: contractErrorCode(RWAError, "InsufficientBalance"),
name: "InsufficientBalance",
})
})

it("refuses a transaction whose authorization is already signed", async () => {
Expand Down Expand Up @@ -875,14 +884,18 @@ describe("prepareCoSigned", () => {

it("reports the contract's refusal from the simulation", async () => {
const pending = await authorizedTransfer()
simulateMock.mockResolvedValue(refused(304))
simulateMock.mockResolvedValue(refused("IdentityVerificationFailed"))

await expect(
prepareCoSigned(
assembled(pending.transaction.toEnvelope().toXDR("base64")),
pending,
),
).resolves.toEqual({ kind: "refused", code: 304 })
).resolves.toEqual({
kind: "refused",
code: contractErrorCode(RWAError, "IdentityVerificationFailed"),
name: "IdentityVerificationFailed",
})
})

it("refuses a transaction the admin has not authorized", async () => {
Expand Down
14 changes: 7 additions & 7 deletions app-lib/multisig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
import { Api } from "@stellar/stellar-sdk/rpc"
import type { AccountSigners } from "./accounts"
import { networkPassphrase } from "./env"
import { parseErrorCode } from "./readContract"
import { type ContractError, parseContractError } from "./readContract"
import { server } from "./rpc"

export const OPERATOR_TIMEOUT_SECONDS = 7 * 24 * 60 * 60
Expand Down Expand Up @@ -56,7 +56,7 @@ export type SignAuthEntry = NonNullable<ClientOptions["signAuthEntry"]>

export type CoSignStep =
| { kind: "ready"; xdr: string; pending: PendingTransaction }
| { kind: "refused"; code: number }
| ({ kind: "refused" } & ContractError)

export type SubmitFailure =
| "sequence-consumed"
Expand Down Expand Up @@ -218,10 +218,10 @@ const sameTimeBounds = (a: Transaction, b: Transaction): boolean =>

const simulated = async (
assembled: AssembledTransaction<unknown>,
): Promise<Transaction | number> => {
): Promise<Transaction | ContractError> => {
await assembled.simulate()
const code = parseErrorCode(assembled.simulation)
if (code !== null) return code
const error = parseContractError(assembled.simulation)
if (error !== null) return error
if (
assembled.simulation === undefined ||
!Api.isSimulationSuccess(assembled.simulation) ||
Expand Down Expand Up @@ -253,7 +253,7 @@ export async function authorizePending(
expiration,
})
const built = await simulated(assembled)
if (typeof built === "number") return { kind: "refused", code: built }
if ("code" in built) return { kind: "refused", ...built }
const xdr = encodeEnvelope(built)
const next = decodePending(xdr)
if (
Expand All @@ -276,7 +276,7 @@ export async function prepareCoSigned(
throw new InvalidPendingTransaction("authorization missing")
}
const built = await simulated(assembled)
if (typeof built === "number") return { kind: "refused", code: built }
if ("code" in built) return { kind: "refused", ...built }
const retimed = TransactionBuilder.cloneFrom(built, {
timebounds: undefined,
sorobanData: assembled.simulationData.transactionData,
Expand Down
15 changes: 12 additions & 3 deletions app-lib/readContract.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk"
import type { Api } from "@stellar/stellar-sdk/rpc"
import { beforeEach, describe, expect, it, vi } from "vitest"
import {
contractErrorCode,
simulationError,
} from "../app/src/test/contractErrorCode"
Comment on lines +4 to +7
import { OracleError } from "./clients/nav_oracle/src/index"
import type { DepositRequest, EpochInfo, RedeemRequest } from "./contracts"
import {
decodeTransactionEvents,
Expand Down Expand Up @@ -45,7 +50,7 @@ const errorSimulation: Api.SimulateTransactionErrorResponse = {
latestLedger: 100,
events: [],
_parsed: true,
error: "HostError: Error(Contract, #3006)",
error: simulationError(OracleError, "NoRecord"),
}

describe("readContract", () => {
Expand All @@ -55,7 +60,7 @@ describe("readContract", () => {
expect(read).toEqual({ kind: "value", value: 42n })
})

it("parses the error code out of the simulation, never touching result", async () => {
it("parses the error code out of the simulation and names it, never touching result", async () => {
const read = await readContract(() =>
Promise.resolve({
simulation: errorSimulation,
Expand All @@ -65,7 +70,11 @@ describe("readContract", () => {
}),
)

expect(read).toEqual({ kind: "contract-error", code: 3006 })
expect(read).toEqual({
kind: "contract-error",
code: contractErrorCode(OracleError, "NoRecord"),
name: "NoRecord",
})
})

it("is unreadable when the call itself rejects", async () => {
Expand Down
19 changes: 12 additions & 7 deletions app-lib/readContract.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Address, scValToNative, xdr } from "@stellar/stellar-sdk"
import { Api } from "@stellar/stellar-sdk/rpc"
import { errorName } from "./contractErrors"
import type {
DepositRequest,
EpochInfo,
Expand All @@ -8,21 +9,25 @@ import type {
} from "./contracts"
import { server } from "./rpc"

export type ContractError = { code: number; name: string | undefined }

export type ContractRead<T> =
| { kind: "value"; value: T }
| { kind: "contract-error"; code: number }
| ({ kind: "contract-error" } & ContractError)
| { kind: "archived" }
| { kind: "unreadable" }

const CONTRACT_ERROR = /Error\(Contract, #(\d+)\)/

export function parseErrorCode(
export function parseContractError(
simulation: Api.SimulateTransactionResponse | undefined,
): number | null {
): ContractError | null {
if (simulation === undefined || !Api.isSimulationError(simulation))
return null
const match = simulation.error.match(CONTRACT_ERROR)
return match ? Number(match[1]) : null
if (!match) return null
const code = Number(match[1])
return { code, name: errorName(code) }
}

export async function readContract<T>(
Expand All @@ -36,9 +41,9 @@ export async function readContract<T>(
if (tx.simulation !== undefined && Api.isSimulationRestore(tx.simulation)) {
return { kind: "archived" }
}
const code = parseErrorCode(tx.simulation)
return code !== null
? { kind: "contract-error", code }
const error = parseContractError(tx.simulation)
return error !== null
? { kind: "contract-error", ...error }
: { kind: "value", value: tx.result }
} catch {
return { kind: "unreadable" }
Expand Down
Loading
Loading