@fystack/sdk 0.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/.prettierrc +6 -0
- package/dist/index.cjs +962 -0
- package/dist/index.d.cts +611 -0
- package/dist/index.d.mts +611 -0
- package/dist/index.esm.d.ts +611 -0
- package/dist/index.esm.js +941 -0
- package/dist/index.mjs +941 -0
- package/dist/types/index.d.ts +611 -0
- package/package.json +45 -0
- package/src/api.ts +332 -0
- package/src/config.ts +75 -0
- package/src/index.ts +7 -0
- package/src/payment.ts +268 -0
- package/src/sdk.ts +140 -0
- package/src/signer.ts +392 -0
- package/src/solanaSigner.ts +243 -0
- package/src/types.ts +161 -0
- package/src/utils/statusPoller.ts +82 -0
- package/src/utils.ts +101 -0
- package/test.js +76 -0
- package/tsconfig.json +18 -0
- package/vite-env.d.ts +1 -0
- package/vitest.config.js +10 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { APIService, WalletAddressType, WalletDetail } from './api'
|
|
2
|
+
import { APICredentials, TransactionStatusResponse, TxStatus, TransactionError } from './types'
|
|
3
|
+
import { Environment } from './config'
|
|
4
|
+
import { StatusPoller, StatusPollerOptions } from './utils/statusPoller'
|
|
5
|
+
import { Transaction, PublicKey, VersionedTransaction } from '@solana/web3.js'
|
|
6
|
+
import bs58 from 'bs58'
|
|
7
|
+
import { Buffer } from 'buffer'
|
|
8
|
+
|
|
9
|
+
export class SolanaSigner {
|
|
10
|
+
private address!: string
|
|
11
|
+
private APIService: APIService
|
|
12
|
+
private APIKey: string
|
|
13
|
+
private walletDetail: WalletDetail
|
|
14
|
+
private pollerOptions?: StatusPollerOptions
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
credentials: APICredentials,
|
|
18
|
+
environment: Environment,
|
|
19
|
+
pollerOptions?: StatusPollerOptions
|
|
20
|
+
) {
|
|
21
|
+
this.APIKey = credentials.apiKey
|
|
22
|
+
this.APIService = new APIService(credentials, environment)
|
|
23
|
+
this.pollerOptions = pollerOptions
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
setWallet(walletId: string): void {
|
|
27
|
+
if (!walletId || walletId.trim() === '') {
|
|
28
|
+
throw new Error('Invalid wallet ID provided')
|
|
29
|
+
}
|
|
30
|
+
// Set walletId in walletDetail with default values for required properties
|
|
31
|
+
this.walletDetail = {
|
|
32
|
+
WalletID: walletId,
|
|
33
|
+
APIKey: '',
|
|
34
|
+
Name: '',
|
|
35
|
+
AddressType: '',
|
|
36
|
+
Address: ''
|
|
37
|
+
// Other fields will be populated when getAddress is called
|
|
38
|
+
}
|
|
39
|
+
// Reset address cache since we're changing wallets
|
|
40
|
+
this.address = ''
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async getAddress(): Promise<string> {
|
|
44
|
+
if (this.address) {
|
|
45
|
+
return this.address
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!this.APIKey && !this.walletDetail?.WalletID) {
|
|
49
|
+
throw new Error('Wallet detail not found, use setWallet(walletId) to set wallet first!')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const detail: WalletDetail = await this.APIService.getWalletDetail(
|
|
53
|
+
WalletAddressType.Sol,
|
|
54
|
+
this.walletDetail?.WalletID
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
this.walletDetail = detail
|
|
58
|
+
if (detail?.Address) {
|
|
59
|
+
// cache the address
|
|
60
|
+
this.address = detail.Address
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!this.address) {
|
|
64
|
+
throw new Error('Address not found')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return this.address
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private async waitForTransactionStatus(transactionId: string): Promise<string> {
|
|
71
|
+
const poller = new StatusPoller(this.pollerOptions)
|
|
72
|
+
// Poll for transaction status using signaturePoller
|
|
73
|
+
const result = await poller.poll<TransactionStatusResponse>(
|
|
74
|
+
// Polling function
|
|
75
|
+
() => {
|
|
76
|
+
console.log('Polling status')
|
|
77
|
+
return this.APIService.getTransactionStatus(this.walletDetail.WalletID, transactionId)
|
|
78
|
+
},
|
|
79
|
+
// Success condition
|
|
80
|
+
(result) =>
|
|
81
|
+
(result.status === TxStatus.Confirmed || result.status === TxStatus.Completed) &&
|
|
82
|
+
!!result.hash,
|
|
83
|
+
// Error condition
|
|
84
|
+
(result) => {
|
|
85
|
+
if (result.status === TxStatus.Failed) {
|
|
86
|
+
throw new TransactionError(
|
|
87
|
+
result.failed_reason || 'Transaction failed',
|
|
88
|
+
'TRANSACTION_FAILED',
|
|
89
|
+
result.transaction_id
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
return result.status === TxStatus.Rejected
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
console.log('result', result)
|
|
96
|
+
|
|
97
|
+
if (!result.hash) {
|
|
98
|
+
throw new TransactionError(
|
|
99
|
+
'Transaction hash not found in successful response',
|
|
100
|
+
'TRANSACTION_HASH_MISSING',
|
|
101
|
+
result.transaction_id
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return result.hash
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Signs a Solana transaction
|
|
110
|
+
* @param transaction Base64 encoded serialized transaction
|
|
111
|
+
* @returns Signature as a base58 encoded string
|
|
112
|
+
*/
|
|
113
|
+
async signTransaction(transaction: string): Promise<string> {
|
|
114
|
+
if (!this.address) {
|
|
115
|
+
await this.getAddress()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const data = {
|
|
119
|
+
data: transaction,
|
|
120
|
+
from: this.address
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Call the signRaw API similar to ApexSigner
|
|
124
|
+
const response = await this.APIService.signTransaction(this.walletDetail.WalletID, {
|
|
125
|
+
...data,
|
|
126
|
+
meta: {
|
|
127
|
+
tx_method: 'solana_signTransaction'
|
|
128
|
+
},
|
|
129
|
+
chainId: '1399811149'
|
|
130
|
+
})
|
|
131
|
+
console.log('respnpse', response)
|
|
132
|
+
|
|
133
|
+
// Wait for the signature
|
|
134
|
+
return this.waitForTransactionStatus(response.transaction_id)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Signs a Solana message
|
|
139
|
+
* @param message The message to sign (string or Uint8Array)
|
|
140
|
+
* @returns Signature as a base58 encoded string
|
|
141
|
+
*/
|
|
142
|
+
async signMessage(message: string | Uint8Array): Promise<string> {
|
|
143
|
+
if (!this.address) {
|
|
144
|
+
await this.getAddress()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const messageStr =
|
|
148
|
+
typeof message === 'string' ? message : Buffer.from(message).toString('base64')
|
|
149
|
+
|
|
150
|
+
const response = await this.APIService.requestSign(this.walletDetail.WalletID, {
|
|
151
|
+
method: 'solana_signMessage',
|
|
152
|
+
message: messageStr,
|
|
153
|
+
chain_id: 0 // Not used for Solana but required by API
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
console.log('Respnse', response)
|
|
157
|
+
|
|
158
|
+
return this.waitForTransactionStatus(response.transaction_id)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Signs and sends a Solana transaction
|
|
163
|
+
* @param transaction Base64 encoded serialized transaction
|
|
164
|
+
* @returns Transaction signature
|
|
165
|
+
*/
|
|
166
|
+
async signAndSendTransaction(transaction: string): Promise<string> {
|
|
167
|
+
if (!this.address) {
|
|
168
|
+
await this.getAddress()
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const data = {
|
|
172
|
+
transaction,
|
|
173
|
+
from: this.address,
|
|
174
|
+
method: 'solana_signAndSendTransaction'
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const response = await this.APIService.signTransaction(this.walletDetail.WalletID, data)
|
|
178
|
+
const txHash = await this.waitForTransactionStatus(response.transaction_id)
|
|
179
|
+
console.log('transaction succeed!')
|
|
180
|
+
|
|
181
|
+
return txHash
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Signs multiple Solana transactions
|
|
186
|
+
* @param transactions Array of base64 encoded serialized transactions
|
|
187
|
+
* @returns Array of signatures as base58 encoded strings
|
|
188
|
+
*/
|
|
189
|
+
async signAllTransactions(transactions: string[]): Promise<{ transactions: string[] }> {
|
|
190
|
+
if (!this.address) {
|
|
191
|
+
await this.getAddress()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// We need to get the signatures and then incorporate them into the transactions
|
|
195
|
+
const signaturePromises = transactions.map(async (transaction) => {
|
|
196
|
+
// Get the signature
|
|
197
|
+
const signature = await this.signTransaction(transaction)
|
|
198
|
+
|
|
199
|
+
// Here you would need to incorporate the signature into the transaction
|
|
200
|
+
// This is a placeholder - you'll need to implement actual signature incorporation
|
|
201
|
+
// based on your Solana transaction structure
|
|
202
|
+
const signedTransaction = this.incorporateSignatureIntoTransaction(transaction, signature)
|
|
203
|
+
return signedTransaction
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// Wait for all transactions to be signed in parallel
|
|
207
|
+
const signedTransactions = await Promise.all(signaturePromises)
|
|
208
|
+
return { transactions: signedTransactions }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private incorporateSignatureIntoTransaction(transaction: string, signature: string): string {
|
|
212
|
+
// Decode base64 transaction to buffer
|
|
213
|
+
const transactionBuffer = Buffer.from(transaction, 'base64')
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
// First try with legacy transaction format
|
|
217
|
+
const tx = Transaction.from(transactionBuffer)
|
|
218
|
+
|
|
219
|
+
// Decode the base58 signature
|
|
220
|
+
const signatureBuffer = bs58.decode(signature)
|
|
221
|
+
|
|
222
|
+
// Add the signature to the transaction
|
|
223
|
+
tx.addSignature(new PublicKey(this.address), Buffer.from(signatureBuffer))
|
|
224
|
+
|
|
225
|
+
// Serialize and encode back to base64
|
|
226
|
+
return Buffer.from(tx.serialize()).toString('base64')
|
|
227
|
+
} catch (error) {
|
|
228
|
+
if (error.message.includes('Versioned messages')) {
|
|
229
|
+
// Deserialize as a versioned transaction
|
|
230
|
+
const versionedTx = VersionedTransaction.deserialize(transactionBuffer)
|
|
231
|
+
|
|
232
|
+
// Add the signature (convert from base58)
|
|
233
|
+
const signatureBuffer = bs58.decode(signature)
|
|
234
|
+
versionedTx.signatures[0] = Buffer.from(signatureBuffer)
|
|
235
|
+
|
|
236
|
+
// Serialize and encode back to base64
|
|
237
|
+
return Buffer.from(versionedTx.serialize()).toString('base64')
|
|
238
|
+
}
|
|
239
|
+
// If it's another type of error, rethrow it
|
|
240
|
+
throw error
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
export class TransactionError extends Error {
|
|
2
|
+
constructor(
|
|
3
|
+
message: string,
|
|
4
|
+
public readonly code: string,
|
|
5
|
+
public readonly transactionId?: string,
|
|
6
|
+
public readonly originalError?: Error
|
|
7
|
+
) {
|
|
8
|
+
super(message)
|
|
9
|
+
this.name = 'TransactionError'
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export enum TxStatus {
|
|
13
|
+
Pending = 'pending',
|
|
14
|
+
Completed = 'completed',
|
|
15
|
+
Confirmed = 'confirmed',
|
|
16
|
+
Failed = 'failed',
|
|
17
|
+
PendingApproval = 'pending_approval',
|
|
18
|
+
Rejected = 'rejected'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export enum TxApprovalStatus {
|
|
22
|
+
Pending = 'pending',
|
|
23
|
+
Approved = 'approved',
|
|
24
|
+
Rejected = 'rejected'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface APICredentials {
|
|
28
|
+
apiKey: string
|
|
29
|
+
apiSecret: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface WebhookEvent {
|
|
33
|
+
webhook_id: string // Equivalent to "webhook_id" in Go struct
|
|
34
|
+
resource_id: string // Equivalent to "resource_id" in Go struct
|
|
35
|
+
url: string // URL where the webhook was triggered
|
|
36
|
+
payload: any // Binary or string representation of the payload
|
|
37
|
+
event: string // Name of the event
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SignRequestParams {
|
|
41
|
+
method: string
|
|
42
|
+
message: string
|
|
43
|
+
chain_id: number
|
|
44
|
+
typed_data?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SignResponse {
|
|
48
|
+
transaction_id: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface SignatureStatusResponse {
|
|
52
|
+
status: TxStatus
|
|
53
|
+
signature?: string
|
|
54
|
+
transaction_id: string
|
|
55
|
+
created_at: string
|
|
56
|
+
updated_at: string
|
|
57
|
+
approvals: Array<{
|
|
58
|
+
user_id: string
|
|
59
|
+
status: TxApprovalStatus
|
|
60
|
+
}>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ApprovalInfo {
|
|
64
|
+
user_id: string
|
|
65
|
+
status: TxApprovalStatus
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface TransactionStatusResponse {
|
|
69
|
+
transaction_id: string
|
|
70
|
+
status: TxStatus
|
|
71
|
+
method: string
|
|
72
|
+
hash?: string
|
|
73
|
+
created_at: string
|
|
74
|
+
updated_at: string
|
|
75
|
+
approvals: ApprovalInfo[]
|
|
76
|
+
failed_reason?: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Wallets
|
|
80
|
+
export enum WalletType {
|
|
81
|
+
Standard = 'standard',
|
|
82
|
+
MPC = 'mpc'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CreateWalletOptions {
|
|
86
|
+
name: string
|
|
87
|
+
walletType: WalletType
|
|
88
|
+
}
|
|
89
|
+
export enum WalletCreationStatus {
|
|
90
|
+
Pending = 'pending',
|
|
91
|
+
Success = 'success',
|
|
92
|
+
Error = 'error'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface CreateWalletResponse {
|
|
96
|
+
wallet_id: string
|
|
97
|
+
status: WalletCreationStatus
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface WalletCreationStatusResponse {
|
|
101
|
+
wallet_id: string
|
|
102
|
+
status: WalletCreationStatus
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface WalletAssetNetwork {
|
|
106
|
+
id: string
|
|
107
|
+
created_at: string
|
|
108
|
+
updated_at: string
|
|
109
|
+
name: string
|
|
110
|
+
description?: string
|
|
111
|
+
is_evm: boolean
|
|
112
|
+
chain_id: number
|
|
113
|
+
native_currency: string
|
|
114
|
+
is_testnet?: boolean
|
|
115
|
+
internal_code: string
|
|
116
|
+
explorer_tx: string
|
|
117
|
+
explorer_address: string
|
|
118
|
+
explorer_token: string
|
|
119
|
+
confirmation_blocks: number
|
|
120
|
+
block_interval_in_seconds: number
|
|
121
|
+
disabled: boolean
|
|
122
|
+
logo_url: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface WalletAssetDetail {
|
|
126
|
+
id: string
|
|
127
|
+
created_at: string
|
|
128
|
+
updated_at: string
|
|
129
|
+
name: string
|
|
130
|
+
symbol: string
|
|
131
|
+
decimals: number
|
|
132
|
+
logo_url: string
|
|
133
|
+
is_native: boolean
|
|
134
|
+
address_type: string
|
|
135
|
+
is_whitelisted: boolean
|
|
136
|
+
address?: string
|
|
137
|
+
network_id: string
|
|
138
|
+
network?: WalletAssetNetwork
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface WalletAsset {
|
|
142
|
+
id: string
|
|
143
|
+
created_at: string
|
|
144
|
+
updated_at: string
|
|
145
|
+
wallet_id: string
|
|
146
|
+
asset_id: string
|
|
147
|
+
deposit_address: string
|
|
148
|
+
hidden: boolean
|
|
149
|
+
asset: WalletAssetDetail
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export enum AddressType {
|
|
153
|
+
Evm = 'evm',
|
|
154
|
+
Solana = 'sol'
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface DepositAddressResponse {
|
|
158
|
+
asset_id?: string
|
|
159
|
+
address: string
|
|
160
|
+
qr_code: string
|
|
161
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export interface StatusPollerOptions {
|
|
2
|
+
maxAttempts?: number
|
|
3
|
+
interval?: number
|
|
4
|
+
backoffFactor?: number
|
|
5
|
+
maxInterval?: number
|
|
6
|
+
timeoutMs?: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_POLLER_OPTIONS: StatusPollerOptions = {
|
|
10
|
+
maxAttempts: 10,
|
|
11
|
+
interval: 1000, // Start with 1 second
|
|
12
|
+
backoffFactor: 1.5, // Increase interval by 50% each time
|
|
13
|
+
maxInterval: 10000, // Max 10 seconds between attempts
|
|
14
|
+
timeoutMs: 10 * 60 * 1000 // 10 minutes totla
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class StatusPoller {
|
|
18
|
+
private startTime: number
|
|
19
|
+
private attempts: number
|
|
20
|
+
private currentInterval: number
|
|
21
|
+
private readonly options: Required<StatusPollerOptions>
|
|
22
|
+
|
|
23
|
+
constructor(options: StatusPollerOptions = {}) {
|
|
24
|
+
this.options = { ...DEFAULT_POLLER_OPTIONS, ...options } as Required<StatusPollerOptions>
|
|
25
|
+
this.startTime = Date.now()
|
|
26
|
+
this.attempts = 0
|
|
27
|
+
this.currentInterval = this.options.interval
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private async wait(): Promise<void> {
|
|
31
|
+
await new Promise((resolve) => setTimeout(resolve, this.currentInterval))
|
|
32
|
+
|
|
33
|
+
// Implement exponential backoff
|
|
34
|
+
this.currentInterval = Math.min(
|
|
35
|
+
this.currentInterval * this.options.backoffFactor,
|
|
36
|
+
this.options.maxInterval
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private shouldContinue(): boolean {
|
|
41
|
+
const timeElapsed = Date.now() - this.startTime
|
|
42
|
+
if (timeElapsed >= this.options.timeoutMs) {
|
|
43
|
+
throw new Error(`Status polling timed out after ${timeElapsed}ms`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (this.attempts >= this.options.maxAttempts) {
|
|
47
|
+
throw new Error(`Maximum polling attempts (${this.options.maxAttempts}) exceeded`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async poll<T>(
|
|
54
|
+
pollingFn: () => Promise<T>,
|
|
55
|
+
successCondition: (result: T) => boolean,
|
|
56
|
+
errorCondition?: (result: T) => boolean | void
|
|
57
|
+
): Promise<T> {
|
|
58
|
+
while (this.shouldContinue()) {
|
|
59
|
+
this.attempts++
|
|
60
|
+
|
|
61
|
+
const result = await pollingFn()
|
|
62
|
+
|
|
63
|
+
// Check for error condition first
|
|
64
|
+
if (errorCondition) {
|
|
65
|
+
const shouldError = errorCondition(result)
|
|
66
|
+
if (shouldError) {
|
|
67
|
+
throw new Error('Status polling failed')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Check for success condition
|
|
72
|
+
if (successCondition(result)) {
|
|
73
|
+
return result
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// If neither condition is met, wait and continue polling
|
|
77
|
+
await this.wait()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw new Error('Polling ended without meeting success condition')
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import CryptoJS from 'crypto-js'
|
|
2
|
+
import { WebhookEvent } from './types'
|
|
3
|
+
|
|
4
|
+
export async function computeHMAC(
|
|
5
|
+
apiSecret: string,
|
|
6
|
+
params: Record<string, string>
|
|
7
|
+
): Promise<string> {
|
|
8
|
+
const encodedParams = Object.entries(params)
|
|
9
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
10
|
+
.join('&')
|
|
11
|
+
|
|
12
|
+
const digest = CryptoJS.HmacSHA256(encodedParams, apiSecret)
|
|
13
|
+
return digest.toString(CryptoJS.enc.Hex)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function computeHMACForWebhook(
|
|
17
|
+
apiSecret: string,
|
|
18
|
+
event: WebhookEvent
|
|
19
|
+
): Promise<string> {
|
|
20
|
+
const eventStr = canonicalizeJSON(event)
|
|
21
|
+
console.log('eventStr', eventStr)
|
|
22
|
+
const digest = CryptoJS.HmacSHA256(eventStr, apiSecret)
|
|
23
|
+
return digest.toString(CryptoJS.enc.Hex)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Canonicalizes a TypeScript object by sorting its keys recursively.
|
|
28
|
+
*
|
|
29
|
+
* @param inputObject - The input object to canonicalize.
|
|
30
|
+
* @returns A canonicalized JSON string with sorted keys.
|
|
31
|
+
* @throws Error if the input is not a valid object.
|
|
32
|
+
*/
|
|
33
|
+
export function canonicalizeJSON(inputObject: Record<string, any>): string {
|
|
34
|
+
if (typeof inputObject !== 'object' || inputObject === null) {
|
|
35
|
+
throw new Error('Input must be a non-null object.')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Recursively sorts the keys of an object or processes arrays.
|
|
40
|
+
*
|
|
41
|
+
* @param value - The value to sort (can be an object, array, or primitive).
|
|
42
|
+
* @returns The sorted value.
|
|
43
|
+
*/
|
|
44
|
+
const sortKeys = (value: any): any => {
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
// Recursively sort each element in the array
|
|
47
|
+
return value.map(sortKeys)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (value && typeof value === 'object' && value.constructor === Object) {
|
|
51
|
+
// Sort object keys and recursively sort their values
|
|
52
|
+
return Object.keys(value)
|
|
53
|
+
.sort()
|
|
54
|
+
.reduce((sortedObj: Record<string, any>, key: string) => {
|
|
55
|
+
sortedObj[key] = sortKeys(value[key])
|
|
56
|
+
return sortedObj
|
|
57
|
+
}, {})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Return primitive values as-is
|
|
61
|
+
return value
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Sort the keys recursively
|
|
65
|
+
const sortedObject = sortKeys(inputObject)
|
|
66
|
+
|
|
67
|
+
// Convert the sorted object back into a JSON string
|
|
68
|
+
return JSON.stringify(sortedObject)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validates if a string is a valid UUID v4
|
|
73
|
+
* @param uuid The string to validate
|
|
74
|
+
* @returns boolean indicating if the string is a valid UUID
|
|
75
|
+
*/
|
|
76
|
+
export function isValidUUID(uuid: string): boolean {
|
|
77
|
+
if (!uuid || typeof uuid !== 'string') {
|
|
78
|
+
return false
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// UUID v4 pattern:
|
|
82
|
+
// 8-4-4-4-12 where third group starts with 4 and fourth group starts with 8, 9, a, or b
|
|
83
|
+
const uuidV4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
84
|
+
return uuidV4Regex.test(uuid)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Validates if a string is a valid UUID and throws an error if not
|
|
89
|
+
* @param uuid The string to validate
|
|
90
|
+
* @param paramName The name of the parameter being validated (for error message)
|
|
91
|
+
* @throws Error if the UUID is invalid
|
|
92
|
+
*/
|
|
93
|
+
export function validateUUID(uuid: string, paramName: string): void {
|
|
94
|
+
if (!uuid || typeof uuid !== 'string') {
|
|
95
|
+
throw new Error(`${paramName} is required and must be a string`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!isValidUUID(uuid)) {
|
|
99
|
+
throw new Error(`Invalid ${paramName} format. Must be a valid UUID v4`)
|
|
100
|
+
}
|
|
101
|
+
}
|
package/test.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { TypedDataEncoder, verifyTypedData, Signature } from 'ethers'
|
|
2
|
+
|
|
3
|
+
// 0x2f71f2595162eaca40708bf6d6327d437e760b27d26f3a933389ebdb4eedf3b167008df6f1d403503c82a6d58da5659b14c578019603d6b138b2c03729c92af701
|
|
4
|
+
// 0xd77d10c530b25d1ea6a466eb2b3169138b5ca0d1320ff54bbad000d97f686d0a314c5233844d84da5adf3580afa38c82d9595c22a985acfea02ca722c55df48a00
|
|
5
|
+
// Example usage
|
|
6
|
+
const address = '0xFFe120Fd4D5AB5A9f25b25c30620ac8ee3E1EF21'
|
|
7
|
+
const jsonData = {
|
|
8
|
+
domain: {
|
|
9
|
+
name: 'Permit2',
|
|
10
|
+
chainId: '8453',
|
|
11
|
+
verifyingContract: '0x000000000022d473030f116ddee9f6b43ac78ba3'
|
|
12
|
+
},
|
|
13
|
+
types: {
|
|
14
|
+
PermitSingle: [
|
|
15
|
+
{ name: 'details', type: 'PermitDetails' },
|
|
16
|
+
{ name: 'spender', type: 'address' },
|
|
17
|
+
{ name: 'sigDeadline', type: 'uint256' }
|
|
18
|
+
],
|
|
19
|
+
PermitDetails: [
|
|
20
|
+
{ name: 'token', type: 'address' },
|
|
21
|
+
{ name: 'amount', type: 'uint160' },
|
|
22
|
+
{ name: 'expiration', type: 'uint48' },
|
|
23
|
+
{ name: 'nonce', type: 'uint48' }
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
message: {
|
|
27
|
+
details: {
|
|
28
|
+
token: '0x0b3e328455c4059eeb9e3f84b5543f74e24e7e1b',
|
|
29
|
+
amount: '1461501637330902918203684832716283019655932542975',
|
|
30
|
+
expiration: '1743496074',
|
|
31
|
+
nonce: '0'
|
|
32
|
+
},
|
|
33
|
+
spender: '0x6ff5693b99212da76ad316178a184ab56d299b43',
|
|
34
|
+
sigDeadline: '1740905874'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const signature =
|
|
38
|
+
'0x032f29648f9c2d9d2524aef755c225b96121f2c9b01beee5869a70ef95eb089548dc41f71126dac83c1632738dbea12bf5c925715068e32dda0064701bfc019e00'
|
|
39
|
+
|
|
40
|
+
async function verifySignature(address, jsonData, signature) {
|
|
41
|
+
try {
|
|
42
|
+
// Step 1: Compute the EIP-712 hash
|
|
43
|
+
const computedHash = TypedDataEncoder.hash(jsonData.domain, jsonData.types, jsonData.message)
|
|
44
|
+
console.log('🔹 Computed EIP-712 Hash:', computedHash)
|
|
45
|
+
|
|
46
|
+
// Step 2: Recover the signer address
|
|
47
|
+
const recoveredAddress = verifyTypedData(
|
|
48
|
+
jsonData.domain,
|
|
49
|
+
jsonData.types,
|
|
50
|
+
jsonData.message,
|
|
51
|
+
signature
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
console.log('✅ Recovered Address:', recoveredAddress)
|
|
55
|
+
console.log('🔹 Expected Address:', address)
|
|
56
|
+
|
|
57
|
+
// Step 3: Compare the addresses
|
|
58
|
+
const isValid = recoveredAddress.toLowerCase() === address.toLowerCase()
|
|
59
|
+
console.log('🔹 Is Signature Valid?', isValid)
|
|
60
|
+
|
|
61
|
+
// Step 4: Debug signature parts (r, s, v)
|
|
62
|
+
const sig = Signature.from(signature)
|
|
63
|
+
console.log('🔹 Signature Parts:')
|
|
64
|
+
console.log(' r:', sig.r)
|
|
65
|
+
console.log(' s:', sig.s)
|
|
66
|
+
console.log(' v:', sig.v)
|
|
67
|
+
|
|
68
|
+
return isValid
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error('❌ Signature Verification Failed:', error)
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Run verification
|
|
76
|
+
verifySignature(address, jsonData, signature)
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"declaration": true,
|
|
4
|
+
"declarationDir": "dist/types",
|
|
5
|
+
"rootDirs": ["src", "test"],
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"module": "es2020",
|
|
8
|
+
"target": "es2020",
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"moduleResolution": "node",
|
|
12
|
+
"lib": ["es2020"],
|
|
13
|
+
"strictNullChecks": true,
|
|
14
|
+
"noImplicitAny": true,
|
|
15
|
+
"skipLibCheck": true
|
|
16
|
+
},
|
|
17
|
+
"exclude": ["node_modules", "**/*.test.ts"]
|
|
18
|
+
}
|
package/vite-env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/types/importMeta.d.ts" />
|
package/vitest.config.js
ADDED