Skip to content

Commit 859e632

Browse files
authored
feat: bridge different kind of token in a chain (#148)
1 parent 70e4591 commit 859e632

File tree

4 files changed

+315
-54
lines changed

4 files changed

+315
-54
lines changed

docs/api-reference.md

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -910,6 +910,227 @@ parsed.result.forEach(row => {
910910
});
911911
```
912912

913+
## Bridge Operations
914+
915+
The SDK provides methods for interacting with bridge instances on TN, enabling token transfers between TN and supported blockchain networks.
916+
917+
### Understanding Bridge Identifiers
918+
919+
Bridge instances on TN are identified by specific names that may differ from network names. For example:
920+
- Network `"sepolia"` → Bridge identifier `"sepolia"` (matches)
921+
- Network `"hoodi"` → Bridge identifier `"hoodi_tt"` (different due to multiple token support)
922+
923+
Always use the **bridge identifier** when calling bridge methods, not the network name.
924+
925+
### `client.getWalletBalance(bridgeIdentifier: string, walletAddress: string): Promise<string>`
926+
927+
Gets the wallet balance for a specific bridge instance.
928+
929+
#### Parameters
930+
- `bridgeIdentifier: string` - Bridge instance identifier (e.g., `"sepolia"`, `"hoodi_tt"`, `"ethereum"`)
931+
- `walletAddress: string` - Ethereum address to check balance for
932+
933+
#### Returns
934+
- `Promise<string>` - Balance in wei as a string (to handle large numbers safely)
935+
936+
#### Example
937+
```typescript
938+
// Simple case - identifier matches network name
939+
const sepoliaBalance = await client.getWalletBalance("sepolia", "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb");
940+
console.log(`Balance: ${sepoliaBalance} wei`);
941+
942+
// Multi-token bridge - specify bridge instance explicitly
943+
const hoodiBalance = await client.getWalletBalance("hoodi_tt", "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb");
944+
945+
// Convert wei to human-readable format
946+
import { formatEther } from 'ethers';
947+
const balanceInTokens = formatEther(hoodiBalance);
948+
console.log(`Balance: ${balanceInTokens} tokens`);
949+
```
950+
951+
### `client.withdraw(bridgeIdentifier: string, amount: string, recipient: string): Promise<string>`
952+
953+
Initiates a withdrawal by bridging tokens from TN to a destination chain. This is a convenience method that calls `bridgeTokens` and waits for transaction confirmation.
954+
955+
#### Parameters
956+
- `bridgeIdentifier: string` - Bridge instance identifier (e.g., `"sepolia"`, `"hoodi_tt"`)
957+
- `amount: string` - Amount to withdraw in wei (as string to preserve precision)
958+
- `recipient: string` - Recipient address on the destination chain
959+
960+
#### Returns
961+
- `Promise<string>` - Transaction hash of the withdrawal
962+
963+
#### Example
964+
```typescript
965+
import { parseEther } from 'ethers';
966+
967+
// Withdraw 100 tokens to Sepolia
968+
const amount = parseEther("100"); // Convert to wei
969+
const txHash = await client.withdraw("sepolia", amount.toString(), "0x742d35Cc...");
970+
971+
console.log(`Withdrawal initiated: ${txHash}`);
972+
973+
// For non-custodial bridges (like Hoodi), you must claim the withdrawal manually
974+
// See getWithdrawalProof() for claiming process
975+
```
976+
977+
**Important Notes:**
978+
- **Non-custodial bridges** (Hoodi): You must manually claim withdrawals using `getWithdrawalProof()`
979+
- **Wait time**: Withdrawals become claimable after the epoch period (typically 10 minutes)
980+
981+
### `client.getWithdrawalProof(bridgeIdentifier: string, walletAddress: string): Promise<WithdrawalProof[]>`
982+
983+
Gets withdrawal proofs for claiming withdrawals on non-custodial bridges. Returns merkle proofs and validator signatures needed for submitting claims to the destination chain contract.
984+
985+
#### Parameters
986+
- `bridgeIdentifier: string` - Bridge instance identifier (e.g., `"hoodi_tt"`)
987+
- `walletAddress: string` - Wallet address to get withdrawal proofs for
988+
989+
#### Returns
990+
- `Promise<WithdrawalProof[]>` - Array of withdrawal proofs (empty array if no unclaimed withdrawals)
991+
992+
#### WithdrawalProof Type
993+
```typescript
994+
interface WithdrawalProof {
995+
chain: string; // Source chain name (e.g., "hoodi")
996+
chain_id: string; // Numeric chain ID (e.g., "560048")
997+
contract: string; // Bridge contract address on destination chain
998+
created_at: number; // Block number when withdrawal was created
999+
recipient: string; // Recipient wallet address
1000+
amount: string; // Withdrawal amount in wei
1001+
block_hash: string; // Kwil block hash (base64-encoded)
1002+
root: string; // Merkle root (base64-encoded)
1003+
proofs: string[]; // Merkle proofs (base64-encoded, usually empty)
1004+
signatures: string[]; // Validator signatures (base64-encoded, 65 bytes each)
1005+
}
1006+
```
1007+
1008+
#### Example - Check for Claimable Withdrawals
1009+
```typescript
1010+
// Check for claimable withdrawals
1011+
const proofs = await client.getWithdrawalProof("hoodi_tt", "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb");
1012+
1013+
if (proofs.length === 0) {
1014+
console.log("No withdrawals ready to claim");
1015+
} else {
1016+
console.log(`${proofs.length} withdrawal(s) ready to claim`);
1017+
1018+
for (const proof of proofs) {
1019+
console.log(`Amount: ${proof.amount} wei`);
1020+
console.log(`Recipient: ${proof.recipient}`);
1021+
console.log(`Contract: ${proof.contract}`);
1022+
}
1023+
}
1024+
```
1025+
1026+
#### Example - Claim Withdrawal On-Chain
1027+
```typescript
1028+
import { Contract, ethers } from 'ethers';
1029+
1030+
// 1. Get withdrawal proof from TN
1031+
const proofs = await client.getWithdrawalProof("hoodi_tt", walletAddress);
1032+
if (proofs.length === 0) {
1033+
throw new Error("No withdrawals to claim");
1034+
}
1035+
1036+
const proof = proofs[0];
1037+
1038+
// 2. Decode base64 data for smart contract call
1039+
const blockHash = Buffer.from(proof.block_hash, 'base64');
1040+
const root = Buffer.from(proof.root, 'base64');
1041+
const merkleProofs = proof.proofs.map(p => Buffer.from(p, 'base64'));
1042+
1043+
// 3. Split signatures into v, r, s components
1044+
const signatures = proof.signatures.map(sig => {
1045+
const sigBytes = Buffer.from(sig, 'base64');
1046+
return {
1047+
v: sigBytes[64],
1048+
r: '0x' + sigBytes.slice(0, 32).toString('hex'),
1049+
s: '0x' + sigBytes.slice(32, 64).toString('hex')
1050+
};
1051+
});
1052+
1053+
// 4. Call bridge contract to claim withdrawal
1054+
const bridgeContract = new Contract(proof.contract, BRIDGE_ABI, signer);
1055+
1056+
const tx = await bridgeContract.claimWithdrawal(
1057+
proof.recipient,
1058+
proof.amount,
1059+
'0x' + blockHash.toString('hex'),
1060+
'0x' + root.toString('hex'),
1061+
merkleProofs.map(p => '0x' + p.toString('hex')),
1062+
signatures.map(s => ({ v: s.v, r: s.r, s: s.s }))
1063+
);
1064+
1065+
await tx.wait();
1066+
console.log(`Withdrawal claimed! Tx: ${tx.hash}`);
1067+
```
1068+
1069+
### `action.listWalletRewards(bridgeIdentifier: string, wallet: string, withPending: boolean): Promise<any[]>`
1070+
1071+
Lists wallet rewards for a specific bridge instance. This is a low-level method that directly accesses the bridge extension namespace.
1072+
1073+
**⚠️ Deprecated**: Most users should use `getWithdrawalProof()` instead, which provides a higher-level interface.
1074+
1075+
#### Parameters
1076+
- `bridgeIdentifier: string` - Bridge instance identifier
1077+
- `wallet: string` - Wallet address to query
1078+
- `withPending: boolean` - Whether to include pending (not yet finalized) rewards
1079+
1080+
#### Returns
1081+
- `Promise<any[]>` - Array of reward records
1082+
1083+
#### Example
1084+
```typescript
1085+
const action = client.loadAction();
1086+
const rewards = await action.listWalletRewards("hoodi_tt", walletAddress, true);
1087+
console.log(`Found ${rewards.length} reward(s)`);
1088+
```
1089+
1090+
### Bridge Configuration Best Practices
1091+
1092+
When integrating bridge functionality in your application:
1093+
1094+
1. **Use bridge identifiers directly**:
1095+
```typescript
1096+
// Always use the exact bridge identifier
1097+
const balance = await client.getWalletBalance('hoodi_tt', address);
1098+
const sepoliaBalance = await client.getWalletBalance('sepolia', address);
1099+
1100+
// For multiple Hoodi bridges
1101+
const tt2Balance = await client.getWalletBalance('hoodi_tt2', address);
1102+
```
1103+
1104+
2. **Handle custodial vs non-custodial bridges differently**:
1105+
```typescript
1106+
const isCustodial = {
1107+
ethereum: true, // Auto-claimed
1108+
sepolia: true, // Auto-claimed
1109+
hoodi_tt: false, // Manual claim required
1110+
};
1111+
1112+
if (isCustodial[bridgeId]) {
1113+
console.log("Withdrawal will be automatically claimed");
1114+
} else {
1115+
console.log("You must claim withdrawal manually using getWithdrawalProof()");
1116+
}
1117+
```
1118+
1119+
3. **Poll for withdrawal proofs** on non-custodial bridges:
1120+
```typescript
1121+
async function waitForClaimableWithdrawal(bridgeId: string, address: string, maxAttempts = 60) {
1122+
for (let i = 0; i < maxAttempts; i++) {
1123+
const proofs = await client.getWithdrawalProof(bridgeId, address);
1124+
if (proofs.length > 0) {
1125+
return proofs[0];
1126+
}
1127+
// Wait 10 seconds before checking again
1128+
await new Promise(resolve => setTimeout(resolve, 10000));
1129+
}
1130+
throw new Error("Withdrawal not ready after 10 minutes");
1131+
}
1132+
```
1133+
9131134
## Performance Recommendations
9141135
- Use batch record insertions
9151136
- Implement client-side caching

src/client/client.ts

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -279,65 +279,73 @@ export abstract class BaseTNClient<T extends EnvironmentType> {
279279
return action.listMetadataByHeight(params);
280280
}
281281

282-
async getWalletBalance(chain: string, walletAddress: string) {
282+
/**
283+
* Gets the wallet balance for a specific bridge instance
284+
* @param bridgeIdentifier The bridge instance identifier (e.g., "sepolia", "hoodi_tt", "ethereum")
285+
* @param walletAddress The wallet address to check balance for
286+
* @returns Promise that resolves to the balance as a string (in wei)
287+
*/
288+
async getWalletBalance(bridgeIdentifier: string, walletAddress: string) {
283289
const action = this.loadAction();
284-
return action.getWalletBalance(chain, walletAddress);
290+
return action.getWalletBalance(bridgeIdentifier, walletAddress);
285291
}
286292

287293
/**
288-
* Performs a withdrawal operation by bridging tokens
289-
* @param chain The chain identifier (e.g., "sepolia", "mainnet", "polygon", etc.)
290-
* @param amount The amount to withdraw
294+
* Performs a withdrawal operation by bridging tokens from TN to a destination chain
295+
* @param bridgeIdentifier The bridge instance identifier (e.g., "sepolia", "hoodi_tt")
296+
* @param amount The amount to withdraw (in wei)
297+
* @param recipient The recipient address on the destination chain
291298
* @returns Promise that resolves to the transaction hash, or throws on error
292299
*/
293-
async withdraw(chain: string, amount: string, recipient: string): Promise<string> {
300+
async withdraw(bridgeIdentifier: string, amount: string, recipient: string): Promise<string> {
294301
const action = this.loadAction();
295-
302+
296303
// Bridge tokens in a single operation
297-
const bridgeResult = await action.bridgeTokens(chain, amount, recipient);
304+
const bridgeResult = await action.bridgeTokens(bridgeIdentifier, amount, recipient);
298305
if (!bridgeResult.data?.tx_hash) {
299306
throw new Error("Bridge tokens operation failed: no transaction hash returned");
300307
}
301-
308+
302309
// Wait for bridge transaction to be mined - let waitForTx errors bubble up
303310
try {
304311
await this.waitForTx(bridgeResult.data.tx_hash);
305312
} catch (error) {
306313
throw new Error(`Bridge tokens transaction failed: ${error instanceof Error ? error.message : String(error)}`);
307314
}
308-
315+
309316
// Return the transaction hash
310317
return bridgeResult.data.tx_hash;
311318
}
312319

313320
/**
314-
* Lists wallet rewards for a specific wallet address on a blockchain network
315-
* @param chain The chain identifier (e.g., "sepolia", "mainnet", "polygon", etc.)
321+
* Lists wallet rewards for a specific bridge instance
322+
* @param bridgeIdentifier The bridge instance identifier (e.g., "sepolia", "hoodi_tt")
316323
* @param wallet The wallet address to list rewards for
317324
* @param withPending Whether to include pending rewards
318325
* @returns Promise that resolves to an array of rewards data
326+
* @deprecated This method uses the extension namespace directly. Most users should use getWithdrawalProof instead.
319327
*/
320-
async listWalletRewards(chain: string, wallet: string, withPending: boolean): Promise<any[]> {
328+
async listWalletRewards(bridgeIdentifier: string, wallet: string, withPending: boolean): Promise<any[]> {
321329
const action = this.loadAction();
322-
return action.listWalletRewards(chain, wallet, withPending);
330+
return action.listWalletRewards(bridgeIdentifier, wallet, withPending);
323331
}
324332

325333
/**
326-
* Gets withdrawal proof for a specific wallet address on a blockchain network
327-
* Returns merkle proofs and validator signatures needed for withdrawal
334+
* Gets withdrawal proof for a specific bridge instance
335+
* Returns merkle proofs and validator signatures needed for claiming withdrawals on the destination chain
328336
*
329337
* This method is used for non-custodial bridge withdrawals where users need to
330-
* manually claim their withdrawals by submitting proofs to the destination chain.
338+
* manually claim their withdrawals by submitting proofs to the destination chain contract.
331339
* The proof includes validator signatures, merkle root, block hash, and amount.
332340
*
333-
* @param chain The chain identifier (e.g., "hoodi", "sepolia", etc.)
341+
* @param bridgeIdentifier The bridge instance identifier (e.g., "hoodi_tt", "sepolia", "ethereum")
334342
* @param walletAddress The wallet address to get withdrawal proof for
335-
* @returns Promise that resolves to an array of withdrawal proof data
343+
* @returns Promise that resolves to an array of withdrawal proof data (empty array if no unclaimed withdrawals)
336344
*
337345
* @example
338346
* ```typescript
339-
* // Get withdrawal proofs for Hoodi
340-
* const proofs = await client.getWithdrawalProof("hoodi", "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb");
347+
* // Get withdrawal proofs for Hoodi Test Token bridge
348+
* const proofs = await client.getWithdrawalProof("hoodi_tt", "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb");
341349
*
342350
* // Proofs will be an array like:
343351
* // [{
@@ -352,14 +360,20 @@ export abstract class BaseTNClient<T extends EnvironmentType> {
352360
* // proofs: [],
353361
* // signatures: [<base64-encoded-signatures>]
354362
* // }]
363+
*
364+
* // Use the proofs to claim withdrawal on destination chain
365+
* if (proofs.length > 0) {
366+
* const proof = proofs[0];
367+
* await bridgeContract.claimWithdrawal(proof.recipient, proof.amount, proof.root, proof.proofs, proof.signatures);
368+
* }
355369
* ```
356370
*
357371
* @note This method has been tested via integration tests in the node repository.
358372
* See: https://github.com/trufnetwork/kwil-db/blob/main/node/exts/erc20-bridge/erc20/meta_extension_withdrawal_test.go
359373
*/
360-
async getWithdrawalProof(chain: string, walletAddress: string): Promise<WithdrawalProof[]> {
374+
async getWithdrawalProof(bridgeIdentifier: string, walletAddress: string): Promise<WithdrawalProof[]> {
361375
const action = this.loadAction();
362-
return action.getWithdrawalProof(chain, walletAddress);
376+
return action.getWithdrawalProof(bridgeIdentifier, walletAddress);
363377
}
364378

365379
/**

0 commit comments

Comments
 (0)