@portkey/ca-agent-skills 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +270 -0
- package/README.zh-CN.md +265 -0
- package/bin/platforms/claude.ts +36 -0
- package/bin/platforms/cursor.ts +39 -0
- package/bin/platforms/openclaw.ts +39 -0
- package/bin/platforms/utils.ts +144 -0
- package/bin/setup.ts +191 -0
- package/cli-helpers.ts +26 -0
- package/index.ts +145 -0
- package/lib/aelf-client.ts +281 -0
- package/lib/aelf-sdk.d.ts +103 -0
- package/lib/config.ts +46 -0
- package/lib/http.ts +197 -0
- package/lib/types.ts +492 -0
- package/mcp-config.example.json +12 -0
- package/openclaw.json +197 -0
- package/package.json +49 -0
- package/portkey_auth_skill.ts +173 -0
- package/portkey_query_skill.ts +147 -0
- package/portkey_tx_skill.ts +143 -0
- package/src/core/account.ts +230 -0
- package/src/core/assets.ts +175 -0
- package/src/core/auth.ts +310 -0
- package/src/core/contract.ts +118 -0
- package/src/core/guardian.ts +141 -0
- package/src/core/keystore.ts +319 -0
- package/src/core/transfer.ts +243 -0
- package/src/mcp/server.ts +756 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import AElf from 'aelf-sdk';
|
|
2
|
+
import type { WalletInfo, TransactionResult } from './types.js';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Types (aelf-sdk does not ship clean TS types, see ./aelf-sdk.d.ts)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export interface AElfWallet {
|
|
9
|
+
address: string;
|
|
10
|
+
privateKey: string;
|
|
11
|
+
mnemonic?: string;
|
|
12
|
+
BIP44Path?: string;
|
|
13
|
+
childWallet?: unknown;
|
|
14
|
+
keyPair?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
18
|
+
export type AElfInstance = InstanceType<typeof AElf>;
|
|
19
|
+
export type AElfContract = Record<string, any>;
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Instance & contract caches
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
const aelfInstanceCache: Record<string, AElfInstance> = {};
|
|
26
|
+
const contractCache: Record<string, AElfContract> = {};
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Wallet helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/** Create a brand-new wallet (manager keypair). */
|
|
33
|
+
export function createWallet(): WalletInfo {
|
|
34
|
+
const w = AElf.wallet.createNewWallet();
|
|
35
|
+
return {
|
|
36
|
+
address: w.address,
|
|
37
|
+
privateKey: w.privateKey,
|
|
38
|
+
mnemonic: w.mnemonic,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Restore a wallet from a private key string. */
|
|
43
|
+
export function getWalletByPrivateKey(privateKey: string): AElfWallet {
|
|
44
|
+
return AElf.wallet.getWalletByPrivateKey(privateKey) as AElfWallet;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// AElf instance
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** Get (or create) an AElf SDK instance for the given RPC URL. */
|
|
52
|
+
export function getAelfInstance(rpcUrl: string): AElfInstance {
|
|
53
|
+
if (!aelfInstanceCache[rpcUrl]) {
|
|
54
|
+
aelfInstanceCache[rpcUrl] = new AElf(new AElf.providers.HttpProvider(rpcUrl, 20_000));
|
|
55
|
+
}
|
|
56
|
+
return aelfInstanceCache[rpcUrl];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Contract instance
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Get (or create) a contract instance.
|
|
65
|
+
* - For view-only calls, `wallet` can be any wallet (a default one is fine).
|
|
66
|
+
* - For send calls, `wallet` must be the manager wallet that owns the CA.
|
|
67
|
+
*/
|
|
68
|
+
export async function getContractInstance(
|
|
69
|
+
rpcUrl: string,
|
|
70
|
+
contractAddress: string,
|
|
71
|
+
wallet: AElfWallet,
|
|
72
|
+
): Promise<AElfContract> {
|
|
73
|
+
const key = `${rpcUrl}|${contractAddress}|${wallet.address}`;
|
|
74
|
+
if (!contractCache[key]) {
|
|
75
|
+
const instance = getAelfInstance(rpcUrl);
|
|
76
|
+
contractCache[key] = await instance.chain.contractAt(contractAddress, wallet);
|
|
77
|
+
}
|
|
78
|
+
return contractCache[key];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Contract call helpers
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/** Call a read-only (view) method on a contract. */
|
|
86
|
+
export async function callViewMethod<T = unknown>(
|
|
87
|
+
rpcUrl: string,
|
|
88
|
+
contractAddress: string,
|
|
89
|
+
methodName: string,
|
|
90
|
+
params?: Record<string, unknown>,
|
|
91
|
+
): Promise<T> {
|
|
92
|
+
// View methods don't require a real identity — use a random ephemeral wallet.
|
|
93
|
+
// NEVER hard-code a real private key here.
|
|
94
|
+
const defaultWallet = createWallet();
|
|
95
|
+
const contract = await getContractInstance(rpcUrl, contractAddress, defaultWallet);
|
|
96
|
+
|
|
97
|
+
const method = contract[methodName];
|
|
98
|
+
if (!method || typeof method?.call !== 'function') {
|
|
99
|
+
throw new Error(`Contract method "${methodName}" not found at ${contractAddress}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const result = await method.call(params ?? {});
|
|
103
|
+
if (result && typeof result === 'object' && 'error' in result && result.error) {
|
|
104
|
+
throw new Error(`View call ${methodName} failed: ${JSON.stringify(result.error)}`);
|
|
105
|
+
}
|
|
106
|
+
// aelf-sdk wraps result in { result } or returns directly
|
|
107
|
+
return ((result && typeof result === 'object' && 'result' in result ? result.result : result) ?? result) as T;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Call a state-changing (send) method on a contract. Returns the TX result. */
|
|
111
|
+
export async function callSendMethod(
|
|
112
|
+
rpcUrl: string,
|
|
113
|
+
contractAddress: string,
|
|
114
|
+
wallet: AElfWallet,
|
|
115
|
+
methodName: string,
|
|
116
|
+
params: Record<string, unknown>,
|
|
117
|
+
): Promise<{ transactionId: string; data: TransactionResult }> {
|
|
118
|
+
const contract = await getContractInstance(rpcUrl, contractAddress, wallet);
|
|
119
|
+
|
|
120
|
+
const method = contract[methodName];
|
|
121
|
+
if (!method || typeof method !== 'function') {
|
|
122
|
+
throw new Error(`Contract method "${methodName}" not found at ${contractAddress}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const sendResult = await method(params);
|
|
126
|
+
const txId = sendResult?.result?.TransactionId || sendResult?.TransactionId;
|
|
127
|
+
|
|
128
|
+
if (sendResult && typeof sendResult === 'object' && 'error' in sendResult && sendResult.error) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`Send call ${methodName} failed: ${JSON.stringify(sendResult.error)}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!txId) {
|
|
135
|
+
throw new Error(`Send call ${methodName} did not return a TransactionId`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Wait for TX to be mined, then fetch result
|
|
139
|
+
await sleep(1000);
|
|
140
|
+
const txResult = await getTxResult(rpcUrl, txId);
|
|
141
|
+
return { transactionId: txId, data: txResult };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// ManagerForwardCall parameter encoding
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Encode parameters for ManagerForwardCall.
|
|
150
|
+
*
|
|
151
|
+
* ManagerForwardCall expects `args` as protobuf-encoded bytes of the target
|
|
152
|
+
* method's input type. This function:
|
|
153
|
+
* 1. Fetches the target contract's FileDescriptorSet
|
|
154
|
+
* 2. Finds the target method's input protobuf type
|
|
155
|
+
* 3. Encodes the args using that type
|
|
156
|
+
*
|
|
157
|
+
* Returns the full ManagerForwardCall params ready to be sent.
|
|
158
|
+
*/
|
|
159
|
+
export async function encodeManagerForwardCallParams(
|
|
160
|
+
rpcUrl: string,
|
|
161
|
+
params: {
|
|
162
|
+
caHash: string;
|
|
163
|
+
contractAddress: string;
|
|
164
|
+
methodName: string;
|
|
165
|
+
args: Record<string, unknown>;
|
|
166
|
+
},
|
|
167
|
+
): Promise<Record<string, unknown>> {
|
|
168
|
+
const instance = getAelfInstance(rpcUrl);
|
|
169
|
+
|
|
170
|
+
// Get target contract's protobuf descriptors
|
|
171
|
+
const fds = await instance.chain.getContractFileDescriptorSet(params.contractAddress);
|
|
172
|
+
const root = AElf.pbjs.Root.fromDescriptor(fds, 'proto3');
|
|
173
|
+
|
|
174
|
+
// Find the method's input type across all services
|
|
175
|
+
let inputType: unknown = null;
|
|
176
|
+
const cleanMethodName = params.methodName.replace('.', '');
|
|
177
|
+
|
|
178
|
+
for (const svc of root.nestedArray) {
|
|
179
|
+
if ((svc as { methods?: Record<string, unknown> }).methods) {
|
|
180
|
+
const service = svc as { methods: Record<string, { resolvedRequestType?: unknown }> };
|
|
181
|
+
const method = service.methods[cleanMethodName];
|
|
182
|
+
if (method?.resolvedRequestType) {
|
|
183
|
+
inputType = method.resolvedRequestType;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Also check nested namespaces
|
|
188
|
+
if ((svc as { nestedArray?: unknown[] }).nestedArray) {
|
|
189
|
+
for (const nested of (svc as { nestedArray: { methods?: Record<string, { resolvedRequestType?: unknown }> }[] }).nestedArray) {
|
|
190
|
+
if (nested.methods?.[cleanMethodName]?.resolvedRequestType) {
|
|
191
|
+
inputType = nested.methods[cleanMethodName].resolvedRequestType;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!inputType) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Method "${params.methodName}" not found in contract ${params.contractAddress}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Encode args using protobuf
|
|
205
|
+
const type = inputType as {
|
|
206
|
+
fromObject: (obj: unknown) => unknown;
|
|
207
|
+
encode: (msg: unknown) => { finish: () => Uint8Array };
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
let input = params.args;
|
|
211
|
+
// Apply aelf-sdk transforms if available
|
|
212
|
+
if (AElf.utils?.transform?.transformMapToArray) {
|
|
213
|
+
input = AElf.utils.transform.transformMapToArray(inputType, input);
|
|
214
|
+
}
|
|
215
|
+
if (AElf.utils?.transform?.transform && AElf.utils?.transform?.INPUT_TRANSFORMERS) {
|
|
216
|
+
input = AElf.utils.transform.transform(
|
|
217
|
+
inputType,
|
|
218
|
+
input,
|
|
219
|
+
AElf.utils.transform.INPUT_TRANSFORMERS,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const message = type.fromObject(input);
|
|
224
|
+
const encodedArgs = type.encode(message).finish();
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
caHash: params.caHash,
|
|
228
|
+
contractAddress: params.contractAddress,
|
|
229
|
+
methodName: params.methodName,
|
|
230
|
+
args: encodedArgs,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// Transaction result polling
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
const TX_RESULT_MAX_RETRIES = 20;
|
|
239
|
+
const TX_RESULT_RETRY_DELAY = 1500;
|
|
240
|
+
|
|
241
|
+
/** Poll for a transaction result until it is mined or fails. */
|
|
242
|
+
export async function getTxResult(
|
|
243
|
+
rpcUrl: string,
|
|
244
|
+
txId: string,
|
|
245
|
+
maxRetries = TX_RESULT_MAX_RETRIES,
|
|
246
|
+
): Promise<TransactionResult> {
|
|
247
|
+
const instance = getAelfInstance(rpcUrl);
|
|
248
|
+
|
|
249
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
250
|
+
try {
|
|
251
|
+
const result = await instance.chain.getTxResult(txId);
|
|
252
|
+
if (result.Status === 'MINED' || result.Status === 'FAILED') {
|
|
253
|
+
if (result.Status === 'FAILED') {
|
|
254
|
+
throw new Error(`Transaction ${txId} FAILED: ${result.Error || 'Unknown error'}`);
|
|
255
|
+
}
|
|
256
|
+
return result as TransactionResult;
|
|
257
|
+
}
|
|
258
|
+
} catch (err: unknown) {
|
|
259
|
+
// If the error is our own FAILED error, re-throw
|
|
260
|
+
if (err instanceof Error && err.message.includes('FAILED')) throw err;
|
|
261
|
+
// Otherwise it might be "not found yet", keep retrying
|
|
262
|
+
}
|
|
263
|
+
await sleep(TX_RESULT_RETRY_DELAY);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
throw new Error(`Transaction ${txId} not confirmed after ${maxRetries} retries`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// Helpers
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
function sleep(ms: number): Promise<void> {
|
|
274
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Clear all caches (useful for testing). */
|
|
278
|
+
export function clearCaches(): void {
|
|
279
|
+
for (const key of Object.keys(aelfInstanceCache)) delete aelfInstanceCache[key];
|
|
280
|
+
for (const key of Object.keys(contractCache)) delete contractCache[key];
|
|
281
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
declare module 'aelf-sdk' {
|
|
2
|
+
interface HttpProvider {
|
|
3
|
+
new (host: string, timeout?: number): HttpProvider;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
interface WalletModule {
|
|
7
|
+
createNewWallet(): {
|
|
8
|
+
address: string;
|
|
9
|
+
privateKey: string;
|
|
10
|
+
mnemonic: string;
|
|
11
|
+
BIP44Path: string;
|
|
12
|
+
childWallet: unknown;
|
|
13
|
+
keyPair: unknown;
|
|
14
|
+
};
|
|
15
|
+
getWalletByPrivateKey(privateKey: string): {
|
|
16
|
+
address: string;
|
|
17
|
+
privateKey: string;
|
|
18
|
+
keyPair: unknown;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ChainModule {
|
|
23
|
+
contractAt(
|
|
24
|
+
contractAddress: string,
|
|
25
|
+
wallet: { address: string; privateKey?: string },
|
|
26
|
+
): Promise<Record<string, any>>;
|
|
27
|
+
getTxResult(txId: string): Promise<Record<string, any>>;
|
|
28
|
+
getContractFileDescriptorSet(address: string): Promise<any>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PbjsModule {
|
|
32
|
+
Root: {
|
|
33
|
+
fromDescriptor(descriptor: any, syntax?: string): any;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface UtilsModule {
|
|
38
|
+
transform?: {
|
|
39
|
+
transformMapToArray(type: any, params: any): any;
|
|
40
|
+
transform(type: any, params: any, transformers: any): any;
|
|
41
|
+
INPUT_TRANSFORMERS: any;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class AElf {
|
|
46
|
+
constructor(provider: HttpProvider);
|
|
47
|
+
chain: ChainModule;
|
|
48
|
+
currentProvider: { host: string };
|
|
49
|
+
static providers: { HttpProvider: new (host: string, timeout?: number) => HttpProvider };
|
|
50
|
+
static wallet: WalletModule;
|
|
51
|
+
static pbjs: PbjsModule;
|
|
52
|
+
static utils: UtilsModule;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default AElf;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
declare module 'aelf-sdk/src/util/keyStore.js' {
|
|
59
|
+
interface KeystoreWalletInfo {
|
|
60
|
+
privateKey: string;
|
|
61
|
+
mnemonic: string;
|
|
62
|
+
address?: string;
|
|
63
|
+
nickName?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface KeystoreObject {
|
|
67
|
+
version: number;
|
|
68
|
+
type: string;
|
|
69
|
+
nickName?: string;
|
|
70
|
+
address: string;
|
|
71
|
+
crypto: {
|
|
72
|
+
cipher: string;
|
|
73
|
+
ciphertext: string;
|
|
74
|
+
cipherparams: { iv: string };
|
|
75
|
+
mnemonicEncrypted: string;
|
|
76
|
+
kdf: string;
|
|
77
|
+
kdfparams: {
|
|
78
|
+
r: number;
|
|
79
|
+
n: number;
|
|
80
|
+
p: number;
|
|
81
|
+
dklen: number;
|
|
82
|
+
salt: string;
|
|
83
|
+
};
|
|
84
|
+
mac: string;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getKeystore(
|
|
89
|
+
walletInfo: KeystoreWalletInfo,
|
|
90
|
+
password: string,
|
|
91
|
+
option?: Record<string, unknown>,
|
|
92
|
+
): KeystoreObject;
|
|
93
|
+
|
|
94
|
+
export function unlockKeystore(
|
|
95
|
+
keyStore: KeystoreObject | Record<string, unknown>,
|
|
96
|
+
password: string,
|
|
97
|
+
): KeystoreWalletInfo;
|
|
98
|
+
|
|
99
|
+
export function checkPassword(
|
|
100
|
+
keyStore: KeystoreObject | Record<string, unknown>,
|
|
101
|
+
password: string,
|
|
102
|
+
): boolean;
|
|
103
|
+
}
|
package/lib/config.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { NetworkType, PortkeyConfig, NetworkDefaults } from './types.js';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Network defaults
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
const NETWORK_DEFAULTS: Record<NetworkType, NetworkDefaults> = {
|
|
8
|
+
mainnet: {
|
|
9
|
+
apiUrl: 'https://aa-portkey.portkey.finance',
|
|
10
|
+
graphqlUrl: 'https://indexer-api.aefinder.io/api/app/graphql/portkey',
|
|
11
|
+
},
|
|
12
|
+
testnet: {
|
|
13
|
+
apiUrl: 'https://aa-portkey-test.portkey.finance',
|
|
14
|
+
graphqlUrl: 'https://test-indexer-api.aefinder.io/api/app/graphql/portkey',
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Config builder
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a PortkeyConfig with the following priority (high -> low):
|
|
24
|
+
* 1. Function parameter `override`
|
|
25
|
+
* 2. CLI arguments (handled by the caller)
|
|
26
|
+
* 3. MCP env block (handled by the caller)
|
|
27
|
+
* 4. Environment variables: PORTKEY_NETWORK, PORTKEY_API_URL, PORTKEY_GRAPHQL_URL
|
|
28
|
+
* 5. Code defaults (mainnet)
|
|
29
|
+
*/
|
|
30
|
+
export function getConfig(override?: Partial<PortkeyConfig> & { network?: NetworkType }): PortkeyConfig {
|
|
31
|
+
const network: NetworkType =
|
|
32
|
+
override?.network || (process.env.PORTKEY_NETWORK as NetworkType) || 'mainnet';
|
|
33
|
+
|
|
34
|
+
const defaults = NETWORK_DEFAULTS[network];
|
|
35
|
+
if (!defaults) {
|
|
36
|
+
throw new Error(`Unknown network: ${network}. Expected "mainnet" or "testnet".`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
network,
|
|
41
|
+
apiUrl: override?.apiUrl || process.env.PORTKEY_API_URL || defaults.apiUrl,
|
|
42
|
+
graphqlUrl: override?.graphqlUrl || process.env.PORTKEY_GRAPHQL_URL || defaults.graphqlUrl,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { NETWORK_DEFAULTS };
|
package/lib/http.ts
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { PortkeyConfig } from './types.js';
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Types
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
export interface HttpRequestOptions {
|
|
8
|
+
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
9
|
+
params?: Record<string, string | number | boolean | undefined>;
|
|
10
|
+
data?: unknown;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
timeout?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ApiResponse<T = unknown> {
|
|
16
|
+
code: string;
|
|
17
|
+
message: string;
|
|
18
|
+
data: T;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// HttpError — structured error for precise matching
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export class HttpError extends Error {
|
|
26
|
+
/** HTTP status code (e.g. 400, 404, 500) */
|
|
27
|
+
statusCode: number;
|
|
28
|
+
/** Portkey API error code (e.g. '3002'), if present */
|
|
29
|
+
errorCode: string | null;
|
|
30
|
+
/** Raw response body text */
|
|
31
|
+
responseBody: string;
|
|
32
|
+
|
|
33
|
+
constructor(statusCode: number, statusText: string, body: string) {
|
|
34
|
+
// Try to extract API error code and message from JSON body
|
|
35
|
+
let errorCode: string | null = null;
|
|
36
|
+
let apiMessage = '';
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(body);
|
|
39
|
+
errorCode = parsed?.code ?? parsed?.Code ?? null;
|
|
40
|
+
apiMessage = parsed?.message ?? parsed?.Message ?? '';
|
|
41
|
+
} catch {
|
|
42
|
+
apiMessage = body;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
super(`HTTP ${statusCode} ${statusText}: ${apiMessage || body}`);
|
|
46
|
+
this.name = 'HttpError';
|
|
47
|
+
this.statusCode = statusCode;
|
|
48
|
+
this.errorCode = errorCode;
|
|
49
|
+
this.responseBody = body;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// HTTP client
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
const DEFAULT_TIMEOUT = 20_000;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Lightweight HTTP client for Portkey Backend API.
|
|
61
|
+
*
|
|
62
|
+
* Usage:
|
|
63
|
+
* const client = createHttpClient(config);
|
|
64
|
+
* const result = await client.get('/api/app/wallet/getRegisterInfo', { params: { ... } });
|
|
65
|
+
* const result = await client.post('/api/app/account/sendVerificationRequest', { data: { ... } });
|
|
66
|
+
*/
|
|
67
|
+
export function createHttpClient(config: PortkeyConfig) {
|
|
68
|
+
const baseUrl = config.apiUrl.replace(/\/$/, '');
|
|
69
|
+
|
|
70
|
+
async function request<T = unknown>(endpoint: string, options: HttpRequestOptions = {}): Promise<T> {
|
|
71
|
+
const { method = 'GET', params, data, headers = {}, timeout = DEFAULT_TIMEOUT } = options;
|
|
72
|
+
|
|
73
|
+
// Build URL with query params
|
|
74
|
+
let url = `${baseUrl}${endpoint}`;
|
|
75
|
+
if (params) {
|
|
76
|
+
const searchParams = new URLSearchParams();
|
|
77
|
+
for (const [key, value] of Object.entries(params)) {
|
|
78
|
+
if (value !== undefined && value !== null) {
|
|
79
|
+
searchParams.set(key, String(value));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const qs = searchParams.toString();
|
|
83
|
+
if (qs) url += `?${qs}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Build fetch options
|
|
87
|
+
const fetchOptions: RequestInit = {
|
|
88
|
+
method,
|
|
89
|
+
headers: {
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
...headers,
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (data && method !== 'GET') {
|
|
96
|
+
fetchOptions.body = JSON.stringify(data);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Timeout via AbortController
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
102
|
+
fetchOptions.signal = controller.signal;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const response = await fetch(url, fetchOptions);
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
const text = await response.text().catch(() => '');
|
|
110
|
+
throw new HttpError(response.status, response.statusText, text);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const json = await response.json() as ApiResponse<T> | T;
|
|
114
|
+
|
|
115
|
+
// Portkey API wraps some responses in { code, message, data }
|
|
116
|
+
if (json && typeof json === 'object' && 'code' in json) {
|
|
117
|
+
const wrapped = json as ApiResponse<T>;
|
|
118
|
+
if (wrapped.code !== '20000' && wrapped.code !== '0') {
|
|
119
|
+
throw new Error(`API error [${wrapped.code}]: ${wrapped.message}`);
|
|
120
|
+
}
|
|
121
|
+
return wrapped.data;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return json as T;
|
|
125
|
+
} catch (err: unknown) {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
128
|
+
throw new Error(`Request timeout after ${timeout}ms: ${method} ${url}`);
|
|
129
|
+
}
|
|
130
|
+
throw err;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
get<T = unknown>(endpoint: string, options?: Omit<HttpRequestOptions, 'method' | 'data'>) {
|
|
136
|
+
return request<T>(endpoint, { ...options, method: 'GET' });
|
|
137
|
+
},
|
|
138
|
+
post<T = unknown>(endpoint: string, options?: Omit<HttpRequestOptions, 'method'>) {
|
|
139
|
+
return request<T>(endpoint, { ...options, method: 'POST' });
|
|
140
|
+
},
|
|
141
|
+
put<T = unknown>(endpoint: string, options?: Omit<HttpRequestOptions, 'method'>) {
|
|
142
|
+
return request<T>(endpoint, { ...options, method: 'PUT' });
|
|
143
|
+
},
|
|
144
|
+
del<T = unknown>(endpoint: string, options?: Omit<HttpRequestOptions, 'method'>) {
|
|
145
|
+
return request<T>(endpoint, { ...options, method: 'DELETE' });
|
|
146
|
+
},
|
|
147
|
+
/** Raw request with full control */
|
|
148
|
+
request,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export type HttpClient = ReturnType<typeof createHttpClient>;
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// SSRF protection — validate user-supplied RPC URLs
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
const PRIVATE_IP_PATTERNS = [
|
|
159
|
+
/^localhost$/i,
|
|
160
|
+
/^127\./,
|
|
161
|
+
/^10\./,
|
|
162
|
+
/^172\.(1[6-9]|2\d|3[01])\./,
|
|
163
|
+
/^192\.168\./,
|
|
164
|
+
/^0\./,
|
|
165
|
+
/^\[::1\]/,
|
|
166
|
+
/^\[fd/i, // IPv6 unique-local
|
|
167
|
+
/^\[fe80:/i, // IPv6 link-local
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Validate a user-supplied RPC URL to prevent SSRF attacks.
|
|
172
|
+
* - Requires https:// (or http:// only for known aelf domains)
|
|
173
|
+
* - Blocks private/internal IP ranges and localhost
|
|
174
|
+
*
|
|
175
|
+
* Throws if the URL is unsafe.
|
|
176
|
+
*/
|
|
177
|
+
export function validateRpcUrl(url: string): void {
|
|
178
|
+
let parsed: URL;
|
|
179
|
+
try {
|
|
180
|
+
parsed = new URL(url);
|
|
181
|
+
} catch {
|
|
182
|
+
throw new Error(`Invalid RPC URL: ${url}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Only allow http(s)
|
|
186
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
187
|
+
throw new Error(`RPC URL must use http(s) protocol, got: ${parsed.protocol}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Block private/internal addresses
|
|
191
|
+
const hostname = parsed.hostname;
|
|
192
|
+
for (const pattern of PRIVATE_IP_PATTERNS) {
|
|
193
|
+
if (pattern.test(hostname)) {
|
|
194
|
+
throw new Error(`RPC URL must not point to a private/internal address: ${hostname}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|