Skip to content

Commit 8971d3e

Browse files
committed
refactor(solana): Improve RPC error detection
RPC errors must also be evaluated at the caching/aggregator layer to avoid rotating to another provider in the event that the first provider fails. Some failures cannot be resolved and need to be aborted immediately. While testing this, it was identified that SolanaError objects can be mangled somewhere in the call stack, such that isSolanaError() doesn't resolve true properly. It's unclear why this is the case, but introduce a secondary option for resolving isSolanaError() via superstruct.
1 parent ce288ec commit 8971d3e

File tree

8 files changed

+249
-36
lines changed

8 files changed

+249
-36
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@across-protocol/sdk",
33
"author": "UMA Team",
4-
"version": "4.3.69",
4+
"version": "4.3.70",
55
"license": "AGPL-3.0",
66
"homepage": "https://docs.across.to/reference/sdk",
77
"files": [

src/arch/svm/SpokeUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ async function _callGetTimestampForSlotWithRetry(
177177
const at = "getTimestampForSlot";
178178
const { __code: code } = err.context;
179179

180-
switch (err.context.__code) {
180+
switch (code) {
181181
case SVM_SLOT_SKIPPED:
182182
case SVM_LONG_TERM_STORAGE_SLOT_SKIPPED:
183183
// No block available for this slot; caller must decide on how to handle this.

src/arch/svm/provider.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,56 @@
1+
import { isSolanaError as _isSolanaError } from "@solana/kit";
2+
import { is, type, number, string } from "superstruct";
3+
14
/**
25
* SVM RPC provider error codes
36
* See https://www.quicknode.com/docs/solana/error-references
47
*/
58
export {
6-
isSolanaError,
79
SOLANA_ERROR__JSON_RPC__SERVER_ERROR_BLOCK_NOT_AVAILABLE as SVM_BLOCK_NOT_AVAILABLE,
810
SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SLOT_SKIPPED as SVM_SLOT_SKIPPED,
911
SOLANA_ERROR__JSON_RPC__SERVER_ERROR_LONG_TERM_STORAGE_SLOT_SKIPPED as SVM_LONG_TERM_STORAGE_SLOT_SKIPPED,
1012
SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE as SVM_TRANSACTION_PREFLIGHT_FAILURE,
1113
} from "@solana/kit";
14+
15+
/**
16+
* Superstruct schema for validating SolanaError structure.
17+
* Handles serialized errors that have lost their prototype chain.
18+
* Uses partial validation to allow additional properties in the context object.
19+
*/
20+
const SolanaErrorStruct = type({
21+
name: string(),
22+
context: type({
23+
__code: number(),
24+
}),
25+
});
26+
27+
/**
28+
* Type definition for SolanaError structure.
29+
* Includes common context properties for better type inference.
30+
*/
31+
export interface SolanaErrorLike {
32+
name: string;
33+
context: {
34+
__code: number;
35+
__serverMessage?: string;
36+
statusCode?: number;
37+
[key: string]: unknown;
38+
};
39+
cause?: unknown;
40+
}
41+
42+
/**
43+
* Enhanced type guard to check if an error is a SolanaError.
44+
*
45+
* This function uses a two-tier approach:
46+
* 1. First attempts the official instanceof-based check from @solana/kit
47+
* 2. Falls back to structural validation using superstruct for errors that have been
48+
* serialized/deserialized (e.g., when crossing async boundaries or through JSON parsing)
49+
*
50+
* @param error The error to check
51+
* @param code Optional error code to match against context.__code
52+
* @returns True if the error is a SolanaError (or has valid SolanaError structure)
53+
*/
54+
export function isSolanaError(error: unknown): error is SolanaErrorLike {
55+
return _isSolanaError(error) || is(error, SolanaErrorStruct);
56+
}

src/arch/svm/utils.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,6 @@ import { BigNumber, Address as SdkAddress, biMin, getMessageHash, isDefined, isU
2828
import { getTimestampForSlot, getSlot, getRelayDataHash } from "./SpokeUtils";
2929
import { AttestedCCTPMessage, EventName, SVMEventNames, SVMProvider } from "./types";
3030
import winston from "winston";
31-
32-
export { isSolanaError } from "@solana/kit";
33-
3431
/**
3532
* Basic void TransactionSigner type
3633
*/

src/providers/solana/quorumFallbackRpcFactory.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1+
import { Logger } from "winston";
12
import { RpcFromTransport, RpcResponse, RpcTransport, SolanaRpcApiFromTransport } from "@solana/kit";
2-
import { CachedSolanaRpcFactory } from "./cachedRpcFactory";
3-
import { SolanaBaseRpcFactory, SolanaClusterRpcFactory } from "./baseRpcFactories";
43
import { isPromiseFulfilled, isPromiseRejected } from "../../utils/TypeGuards";
54
import { compareSvmRpcResults, createSendErrorWithMessage } from "../utils";
6-
import { Logger } from "winston";
5+
import { CachedSolanaRpcFactory } from "./cachedRpcFactory";
6+
import { SolanaBaseRpcFactory, SolanaClusterRpcFactory } from "./baseRpcFactories";
7+
import { shouldFailImmediate } from "./utils";
78

89
// This factory stores multiple Cached RPC factories so that users of this factory can specify multiple RPC providers
910
// and the factory will fallback through them if any RPC calls fail. This factory also implements quorum logic amongst
@@ -64,6 +65,11 @@ export class QuorumFallbackSolanaRpcFactory extends SolanaBaseRpcFactory {
6465
throw error;
6566
}
6667

68+
// If one RPC provider reverted, others likely will too. Skip them.
69+
if (quorumThreshold === 1 && shouldFailImmediate(method, error)) {
70+
throw error;
71+
}
72+
6773
const currentFactory = factory.rpcFactory.clusterUrl;
6874
const nextFactory = fallbackFactories.shift()!;
6975
this.logger.debug({

src/providers/solana/retryRpcFactory.ts

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1+
import { Logger } from "winston";
12
import { RpcTransport, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR } from "@solana/kit";
23
import { getThrowSolanaErrorResponseTransformer } from "@solana/rpc-transformers";
4+
import { isSolanaError } from "../../arch/svm";
5+
import { delay } from "../../utils";
36
import { SolanaClusterRpcFactory } from "./baseRpcFactories";
47
import { RateLimitedSolanaRpcFactory } from "./rateLimitedRpcFactory";
5-
import { isSolanaError, SVM_SLOT_SKIPPED, SVM_LONG_TERM_STORAGE_SLOT_SKIPPED } from "../../arch/svm";
6-
import { delay } from "../../utils";
7-
import { Logger } from "winston";
8+
import { shouldFailImmediate } from "./utils";
89

910
// This factory adds retry logic on top of the RateLimitedSolanaRpcFactory.
1011
// It follows the same composition pattern as other factories in this module.
@@ -67,7 +68,7 @@ export class RetrySolanaRpcFactory extends SolanaClusterRpcFactory {
6768
getThrowSolanaErrorResponseTransformer()(response, { methodName: method, params });
6869
return response;
6970
} catch (error) {
70-
if (retryAttempt++ >= this.retries || this.shouldFailImmediate(method, error)) {
71+
if (retryAttempt++ >= this.retries || shouldFailImmediate(method, error)) {
7172
throw error;
7273
}
7374

@@ -82,29 +83,6 @@ export class RetrySolanaRpcFactory extends SolanaClusterRpcFactory {
8283
}
8384
}
8485

85-
/**
86-
* Determine whether a Solana RPC error indicates an unrecoverable error that should not be retried.
87-
* @param method RPC method name
88-
* @param error Error object from the RPC call
89-
* @returns True if the request should be aborted immediately, otherwise false
90-
*/
91-
private shouldFailImmediate(method: string, error: unknown): boolean {
92-
if (!isSolanaError(error)) {
93-
return false;
94-
}
95-
96-
// JSON-RPC errors: https://www.quicknode.com/docs/solana/error-references
97-
const { __code: code } = error.context;
98-
switch (method) {
99-
case "getBlock":
100-
case "getBlockTime":
101-
// No block at the requested slot. This may not be correct for blocks > 1 year old.
102-
return [SVM_SLOT_SKIPPED, SVM_LONG_TERM_STORAGE_SLOT_SKIPPED].includes(code);
103-
default:
104-
return false;
105-
}
106-
}
107-
10886
/**
10987
* Identify whether an error thrown was the result of an RPC provider 429 response.
11088
* @param error Error object from the RPC query.

src/providers/solana/utils.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { RpcTransport } from "@solana/rpc-spec";
2+
import { isSolanaError, SVM_SLOT_SKIPPED, SVM_LONG_TERM_STORAGE_SLOT_SKIPPED } from "../../arch/svm";
23

34
/**
45
* This is the type we pass to define a Solana RPC request "task".
@@ -12,3 +13,26 @@ export interface SolanaRateLimitTask {
1213
resolve: (result: unknown) => void;
1314
reject: (err: unknown) => void;
1415
}
16+
17+
/**
18+
* Determine whether a Solana RPC error indicates an unrecoverable error that should not be retried.
19+
* @param method RPC method name.
20+
* @param error Error object from the RPC call.
21+
* @returns True if the request should be aborted immediately, otherwise false.
22+
*/
23+
export function shouldFailImmediate(method: string, error: unknown): boolean {
24+
if (!isSolanaError(error)) {
25+
return false;
26+
}
27+
28+
// JSON-RPC errors: https://www.quicknode.com/docs/solana/error-references
29+
const { __code: code } = error.context;
30+
switch (method) {
31+
case "getBlock":
32+
case "getBlockTime":
33+
// No block at the requested slot. This may not be correct for blocks > 1 year old.
34+
return [SVM_SLOT_SKIPPED, SVM_LONG_TERM_STORAGE_SLOT_SKIPPED].includes(code);
35+
default:
36+
return false;
37+
}
38+
}

test/isSolanaError.test.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import { isSolanaError, SolanaErrorLike, SVM_SLOT_SKIPPED } from "../src/arch/svm/provider";
2+
import { expect } from "./utils";
3+
4+
describe("isSolanaError type guard", () => {
5+
it("should detect a properly structured SolanaError object", () => {
6+
const error = {
7+
name: "SolanaError",
8+
context: {
9+
__code: SVM_SLOT_SKIPPED,
10+
__serverMessage: "Slot was skipped",
11+
},
12+
};
13+
14+
expect(isSolanaError(error)).to.be.true;
15+
});
16+
17+
it("should detect a flattened/serialized SolanaError with null prototype", () => {
18+
// Create a proper SolanaError-like object
19+
const error = {
20+
name: "SolanaError",
21+
context: {
22+
__code: SVM_SLOT_SKIPPED,
23+
__serverMessage: "Slot was skipped",
24+
},
25+
cause: undefined,
26+
};
27+
28+
// Simulate serialization/deserialization which creates null prototype objects
29+
const serialized = JSON.stringify(error);
30+
const flattened = JSON.parse(serialized);
31+
32+
// Verify the flattened error has lost its prototype chain
33+
expect(Object.getPrototypeOf(flattened)).to.not.equal(Error.prototype);
34+
35+
// The type guard should still detect it as a SolanaError
36+
expect(isSolanaError(flattened)).to.be.true;
37+
});
38+
39+
it("should provide proper type inference for detected errors", () => {
40+
const error = {
41+
name: "SolanaError",
42+
context: {
43+
__code: -32009,
44+
__serverMessage: "Test error",
45+
statusCode: 429,
46+
},
47+
};
48+
49+
if (isSolanaError(error)) {
50+
// These should all be accessible without type assertions
51+
const code: number = error.context.__code;
52+
const message: string | undefined = error.context.__serverMessage;
53+
const status: number | undefined = error.context.statusCode;
54+
55+
expect(code).to.equal(-32009);
56+
expect(message).to.equal("Test error");
57+
expect(status).to.equal(429);
58+
} else {
59+
throw new Error("Error should have been detected as SolanaError");
60+
}
61+
});
62+
63+
it("should reject objects missing the required name field", () => {
64+
const error = {
65+
context: {
66+
__code: SVM_SLOT_SKIPPED,
67+
},
68+
};
69+
70+
expect(isSolanaError(error)).to.be.false;
71+
});
72+
73+
it("should reject objects missing the required __code field", () => {
74+
const error = {
75+
name: "SolanaError",
76+
context: {},
77+
};
78+
79+
expect(isSolanaError(error)).to.be.false;
80+
});
81+
82+
it("should reject objects with incorrect field types", () => {
83+
const error = {
84+
name: "SolanaError",
85+
context: {
86+
__code: "not a number", // Should be number
87+
},
88+
};
89+
90+
expect(isSolanaError(error)).to.be.false;
91+
});
92+
93+
it("should reject non-SolanaError objects", () => {
94+
const regularError = new Error("Regular error");
95+
expect(isSolanaError(regularError)).to.be.false;
96+
97+
const randomObject = { foo: "bar" };
98+
expect(isSolanaError(randomObject)).to.be.false;
99+
100+
expect(isSolanaError(null)).to.be.false;
101+
expect(isSolanaError(undefined)).to.be.false;
102+
expect(isSolanaError("string")).to.be.false;
103+
expect(isSolanaError(123)).to.be.false;
104+
});
105+
106+
it("should allow additional properties in context object", () => {
107+
const error = {
108+
name: "SolanaError",
109+
context: {
110+
__code: SVM_SLOT_SKIPPED,
111+
__serverMessage: "Slot was skipped",
112+
statusCode: 500,
113+
customField: "custom value",
114+
anotherField: { nested: "object" },
115+
},
116+
};
117+
118+
expect(isSolanaError(error)).to.be.true;
119+
});
120+
121+
it("should handle errors with cause property", () => {
122+
const innerError = new Error("Inner error");
123+
const error = {
124+
name: "SolanaError",
125+
context: {
126+
__code: SVM_SLOT_SKIPPED,
127+
},
128+
cause: innerError,
129+
};
130+
131+
expect(isSolanaError(error)).to.be.true;
132+
133+
if (isSolanaError(error)) {
134+
expect(error.cause).to.equal(innerError);
135+
}
136+
});
137+
138+
it("should detect errors that have been serialized and deserialized multiple times", () => {
139+
const originalError: SolanaErrorLike = {
140+
name: "SolanaError",
141+
context: {
142+
__code: -32009,
143+
__serverMessage: "Block not available",
144+
statusCode: 500,
145+
},
146+
};
147+
148+
// Serialize and deserialize multiple times
149+
let error: unknown = originalError;
150+
for (let i = 0; i < 3; i++) {
151+
error = JSON.parse(JSON.stringify(error));
152+
}
153+
154+
// Should still be detected as a SolanaError
155+
expect(isSolanaError(error)).to.be.true;
156+
157+
if (isSolanaError(error)) {
158+
expect(error.context.__code).to.equal(-32009);
159+
expect(error.context.__serverMessage).to.equal("Block not available");
160+
expect(error.context.statusCode).to.equal(500);
161+
}
162+
});
163+
});

0 commit comments

Comments
 (0)