@lombard.finance/sdk-sui 1.1.2 → 1.2.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/dist/index.cjs +2 -2
- package/dist/index.js +1733 -1464
- package/package.json +2 -1
- package/src/common/parameters.ts +3 -2
- package/src/const/getConfig.ts +6 -6
- package/src/index.ts +1 -0
- package/src/services/SuiServiceImpl.ts +6 -3
- package/src/web3Sdk/claimLBTC/claimLBTC.ts +29 -0
- package/src/web3Sdk/getBasculeDepositStatus/__tests__/getBasculeDepositStatus.test.ts +111 -0
- package/src/web3Sdk/getBasculeDepositStatus/getBasculeDepositStatus.ts +300 -0
- package/src/web3Sdk/getBasculeDepositStatus/index.ts +1 -0
- package/src/web3Sdk/prepareAmount/prepareCoinsTransaction.ts +3 -5
- package/src/web3Sdk/unstakeLBTC/unstakeLBTC.ts +15 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lombard.finance/sdk-sui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Sui integration for the Lombard SDK - stake BTC and manage LBTC on Sui",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"@lombard.finance/sdk-common": "4.0.0",
|
|
62
62
|
"@mysten/sui": "^1.21.1",
|
|
63
63
|
"@mysten/wallet-standard": "^0.13.7",
|
|
64
|
+
"@noble/hashes": "^1.8.0",
|
|
64
65
|
"@wallet-standard/base": "^1.1.0",
|
|
65
66
|
"@wallet-standard/core": "^1.1.0",
|
|
66
67
|
"vite": "^6.3.5"
|
package/src/common/parameters.ts
CHANGED
|
@@ -9,8 +9,9 @@ type EnvParameter = {
|
|
|
9
9
|
env?: Env;
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
export interface CommonWriteParameters<
|
|
13
|
-
extends
|
|
12
|
+
export interface CommonWriteParameters<
|
|
13
|
+
WF extends Wallet['features'],
|
|
14
|
+
> extends EnvParameter {
|
|
14
15
|
walletClient: WalletWithFeatures<WF>;
|
|
15
16
|
account: WalletAccount;
|
|
16
17
|
}
|
package/src/const/getConfig.ts
CHANGED
|
@@ -19,13 +19,13 @@ const stageConfig: IConfig = {
|
|
|
19
19
|
mint: {
|
|
20
20
|
target:
|
|
21
21
|
// lbtc_v2_current
|
|
22
|
-
'
|
|
22
|
+
'0x1269ce3b46b5647d0e46024185dd24bdb7492ec7840ebc40fdd209150b89a33d::treasury::mint_v2',
|
|
23
23
|
denyList: '0x403',
|
|
24
24
|
},
|
|
25
25
|
redeem: {
|
|
26
26
|
// lbtc_v2_current
|
|
27
27
|
target:
|
|
28
|
-
'
|
|
28
|
+
'0x1269ce3b46b5647d0e46024185dd24bdb7492ec7840ebc40fdd209150b89a33d::treasury::redeem',
|
|
29
29
|
},
|
|
30
30
|
// lbtc_v1_deprecated - This is intentional
|
|
31
31
|
LBTC: '0x2d66430a27565b912f21be970e5ae1e8c0359f0b518c3235b751c75976791ce0::lbtc::LBTC',
|
|
@@ -67,15 +67,15 @@ const testnetConfig: IConfig = {
|
|
|
67
67
|
// https://github.com/lombard-finance/sui-contracts/blob/main/mainnet.json
|
|
68
68
|
const prodConfig: IConfig = {
|
|
69
69
|
mint: {
|
|
70
|
-
//
|
|
70
|
+
// lbtc_v5_current
|
|
71
71
|
target:
|
|
72
|
-
'
|
|
72
|
+
'0x124477c9cffb667be781e867fd29579d3498a47e6b69d06155841ca85bcb02d0::treasury::mint_v2',
|
|
73
73
|
denyList: '0x403',
|
|
74
74
|
},
|
|
75
75
|
redeem: {
|
|
76
|
-
//
|
|
76
|
+
// lbtc_v5_current
|
|
77
77
|
target:
|
|
78
|
-
'
|
|
78
|
+
'0x124477c9cffb667be781e867fd29579d3498a47e6b69d06155841ca85bcb02d0::treasury::redeem',
|
|
79
79
|
},
|
|
80
80
|
// lbtc_v1_deprecated - This is intentional
|
|
81
81
|
LBTC: '0x3e8e9423d80e1774a7ca128fccd8bf5f1f7753be658c5e645929037f7c819040::lbtc::LBTC',
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './const';
|
|
2
2
|
export * from './web3Sdk/claimLBTC';
|
|
3
3
|
export * from './web3Sdk/getBalance';
|
|
4
|
+
export * from './web3Sdk/getBasculeDepositStatus';
|
|
4
5
|
export * from './web3Sdk/signLbtcDestionationAddrSui';
|
|
5
6
|
export * from './web3Sdk/signTermsOfService';
|
|
6
7
|
export * from './web3Sdk/unstakeLBTC';
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { Env, SuiService } from '@lombard.finance/sdk-common';
|
|
10
|
-
import { getFullnodeUrl,SuiClient } from '@mysten/sui/client';
|
|
10
|
+
import { getFullnodeUrl, SuiClient } from '@mysten/sui/client';
|
|
11
11
|
import type {
|
|
12
12
|
SuiChain,
|
|
13
13
|
SuiSignPersonalMessageFeature,
|
|
@@ -28,7 +28,8 @@ type ProviderResolver = () => Promise<unknown>;
|
|
|
28
28
|
/**
|
|
29
29
|
* Sui wallet features required for signing and transactions
|
|
30
30
|
*/
|
|
31
|
-
type SuiWalletFeatures = SuiSignPersonalMessageFeature &
|
|
31
|
+
type SuiWalletFeatures = SuiSignPersonalMessageFeature &
|
|
32
|
+
SuiSignTransactionFeature;
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Sui wallet provider interface
|
|
@@ -41,7 +42,9 @@ interface SuiWalletProvider {
|
|
|
41
42
|
/**
|
|
42
43
|
* Get Sui network from chain ID
|
|
43
44
|
*/
|
|
44
|
-
function getSuiNetworkFromChainId(
|
|
45
|
+
function getSuiNetworkFromChainId(
|
|
46
|
+
chainId: string,
|
|
47
|
+
): 'mainnet' | 'testnet' | 'devnet' {
|
|
45
48
|
if (chainId.includes('mainnet')) return 'mainnet';
|
|
46
49
|
if (chainId.includes('testnet')) return 'testnet';
|
|
47
50
|
return 'devnet';
|
|
@@ -7,6 +7,10 @@ import { WalletWithFeatures } from '@wallet-standard/base';
|
|
|
7
7
|
import type { WalletAccount } from '@wallet-standard/core';
|
|
8
8
|
|
|
9
9
|
import { getConfig } from '../../const';
|
|
10
|
+
import {
|
|
11
|
+
getBasculeDepositStatus,
|
|
12
|
+
SuiBasculeDepositStatus,
|
|
13
|
+
} from '../getBasculeDepositStatus';
|
|
10
14
|
|
|
11
15
|
type Not0xPrefixedHex = string;
|
|
12
16
|
|
|
@@ -34,6 +38,31 @@ export async function claimLBTC({
|
|
|
34
38
|
client,
|
|
35
39
|
env = DEFAULT_ENV,
|
|
36
40
|
}: IClaimLBTCParams): Promise<SuiTransactionBlockResponse> {
|
|
41
|
+
// Pre-flight the Bascule status so we surface a clear error instead of an
|
|
42
|
+
// opaque on-chain abort from bascule::validate_withdrawal (mirrors the EVM
|
|
43
|
+
// SDK's claimLBTC). The mint targets the same Bascule object this checks.
|
|
44
|
+
const basculeStatus = await getBasculeDepositStatus({ client, payload, env });
|
|
45
|
+
if (basculeStatus !== SuiBasculeDepositStatus.REPORTED) {
|
|
46
|
+
switch (basculeStatus) {
|
|
47
|
+
case SuiBasculeDepositStatus.UNREPORTED:
|
|
48
|
+
throw new Error(
|
|
49
|
+
'The deposit cannot be claimed because it is unreported or potentially still pending, please try again later.',
|
|
50
|
+
);
|
|
51
|
+
case SuiBasculeDepositStatus.WITHDRAWN:
|
|
52
|
+
throw new Error(
|
|
53
|
+
'The deposit cannot be claimed because it is withdrawn already.',
|
|
54
|
+
);
|
|
55
|
+
case SuiBasculeDepositStatus.PAUSED:
|
|
56
|
+
throw new Error(
|
|
57
|
+
'The deposit cannot be claimed because the bridge is paused, please try again later.',
|
|
58
|
+
);
|
|
59
|
+
default:
|
|
60
|
+
throw new Error(
|
|
61
|
+
'The deposit cannot be claimed because it is blocked by bridge security.',
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
37
66
|
const transaction = new Transaction();
|
|
38
67
|
|
|
39
68
|
const {
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
deriveDepositId,
|
|
5
|
+
getBasculeDepositStatus,
|
|
6
|
+
SuiBasculeDepositStatus,
|
|
7
|
+
} from '../getBasculeDepositStatus';
|
|
8
|
+
|
|
9
|
+
// A synthetic mint payload: 4-byte selector + 5 big-endian 32-byte words
|
|
10
|
+
// (to_chain, recipient, amount=100000, tx_id, vout=3). The expected deposit id
|
|
11
|
+
// is computed independently (pure-python keccak mirroring Move's to_deposit_id
|
|
12
|
+
// and sui-claimer's payload.go) and pinned here as a regression vector.
|
|
13
|
+
const PAYLOAD =
|
|
14
|
+
'ce25e7c2' +
|
|
15
|
+
'0000000000000000000000000000000000000000000000000000000000000001' + // to_chain
|
|
16
|
+
'a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1' + // recipient
|
|
17
|
+
'00000000000000000000000000000000000000000000000000000000000186a0' + // amount = 100000
|
|
18
|
+
'b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2' + // tx_id
|
|
19
|
+
'0000000000000000000000000000000000000000000000000000000000000003'; // vout = 3
|
|
20
|
+
|
|
21
|
+
const EXPECTED_DEPOSIT_ID =
|
|
22
|
+
'103946673116742332945389452905418796987698243721899433814538596425314178876115';
|
|
23
|
+
|
|
24
|
+
describe('deriveDepositId', () => {
|
|
25
|
+
it('matches the on-chain to_deposit_id derivation', () => {
|
|
26
|
+
expect(deriveDepositId(PAYLOAD)).toBe(EXPECTED_DEPOSIT_ID);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('accepts a 0x-prefixed payload', () => {
|
|
30
|
+
expect(deriveDepositId(`0x${PAYLOAD}`)).toBe(EXPECTED_DEPOSIT_ID);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rejects a payload of the wrong length', () => {
|
|
34
|
+
expect(() => deriveDepositId('ce25e7c2')).toThrow(/length/);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('rejects a payload with non-hex characters', () => {
|
|
38
|
+
// Correct length, but the final byte 'ag' has a non-hex low nibble.
|
|
39
|
+
// Number.parseInt used to accept this as 0x0a; strict validation rejects it.
|
|
40
|
+
expect(() => deriveDepositId(`${PAYLOAD.slice(0, -2)}ag`)).toThrow(
|
|
41
|
+
/non-hex/,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('getBasculeDepositStatus', () => {
|
|
47
|
+
const makeClient = (overrides: {
|
|
48
|
+
paused?: boolean;
|
|
49
|
+
tableId?: string;
|
|
50
|
+
variant?: string | null;
|
|
51
|
+
}) =>
|
|
52
|
+
({
|
|
53
|
+
getObject: vi.fn().mockResolvedValue({
|
|
54
|
+
data: {
|
|
55
|
+
content: {
|
|
56
|
+
dataType: 'moveObject',
|
|
57
|
+
fields: {
|
|
58
|
+
mIsPaused: overrides.paused ?? false,
|
|
59
|
+
mDepositHistory: {
|
|
60
|
+
fields: { id: { id: overrides.tableId ?? '0xtable' } },
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
getDynamicFieldObject: vi.fn().mockResolvedValue(
|
|
67
|
+
overrides.variant
|
|
68
|
+
? {
|
|
69
|
+
data: {
|
|
70
|
+
content: {
|
|
71
|
+
dataType: 'moveObject',
|
|
72
|
+
fields: { value: { variant: overrides.variant } },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
: { data: null, error: { code: 'dynamicFieldNotFound' } },
|
|
77
|
+
),
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
}) as any;
|
|
80
|
+
|
|
81
|
+
it('returns REPORTED for a reported deposit', async () => {
|
|
82
|
+
const status = await getBasculeDepositStatus({
|
|
83
|
+
client: makeClient({ variant: 'Reported' }),
|
|
84
|
+
payload: PAYLOAD,
|
|
85
|
+
});
|
|
86
|
+
expect(status).toBe(SuiBasculeDepositStatus.REPORTED);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns WITHDRAWN for an already-withdrawn deposit', async () => {
|
|
90
|
+
const status = await getBasculeDepositStatus({
|
|
91
|
+
client: makeClient({ variant: 'Withdrawn' }),
|
|
92
|
+
payload: PAYLOAD,
|
|
93
|
+
});
|
|
94
|
+
expect(status).toBe(SuiBasculeDepositStatus.WITHDRAWN);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns UNREPORTED when the deposit is not in the table', async () => {
|
|
98
|
+
const status = await getBasculeDepositStatus({
|
|
99
|
+
client: makeClient({ variant: null }),
|
|
100
|
+
payload: PAYLOAD,
|
|
101
|
+
});
|
|
102
|
+
expect(status).toBe(SuiBasculeDepositStatus.UNREPORTED);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns PAUSED without looking up the deposit when paused', async () => {
|
|
106
|
+
const client = makeClient({ paused: true, variant: 'Reported' });
|
|
107
|
+
const status = await getBasculeDepositStatus({ client, payload: PAYLOAD });
|
|
108
|
+
expect(status).toBe(SuiBasculeDepositStatus.PAUSED);
|
|
109
|
+
expect(client.getDynamicFieldObject).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { DEFAULT_ENV, Env } from '@lombard.finance/sdk-common';
|
|
2
|
+
import type { SuiClient } from '@mysten/sui/client';
|
|
3
|
+
import { keccak_256 } from '@noble/hashes/sha3';
|
|
4
|
+
|
|
5
|
+
import { getConfig } from '../../const';
|
|
6
|
+
|
|
7
|
+
type Not0xPrefixedHex = string;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The Bascule deposit status on Sui, mirroring the EVM SDK's
|
|
11
|
+
* `BasculeDepositStatus`. `PAUSED` is Sui-specific: the on-chain
|
|
12
|
+
* `validate_withdrawal` aborts for every deposit while the Bascule is paused,
|
|
13
|
+
* so there is no point inspecting an individual deposit.
|
|
14
|
+
*/
|
|
15
|
+
export enum SuiBasculeDepositStatus {
|
|
16
|
+
/** Deposit is not reported yet (or unknown) — minting would abort. */
|
|
17
|
+
UNREPORTED = 'UNREPORTED',
|
|
18
|
+
/** Deposit is reported and may be minted. */
|
|
19
|
+
REPORTED = 'REPORTED',
|
|
20
|
+
/** Deposit has already been withdrawn (already minted). */
|
|
21
|
+
WITHDRAWN = 'WITHDRAWN',
|
|
22
|
+
/** The Bascule is paused — no deposit can be minted right now. */
|
|
23
|
+
PAUSED = 'PAUSED',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// On-chain DepositState enum variant names (see bascule::bascule::DepositState).
|
|
27
|
+
const DEPOSIT_REPORTED = 'Reported';
|
|
28
|
+
const DEPOSIT_WITHDRAWN = 'Withdrawn';
|
|
29
|
+
|
|
30
|
+
// Mint payload layout (matches consortium::payload_decoder::decode_mint_payload):
|
|
31
|
+
// a 4-byte action selector followed by 5 big-endian 32-byte words:
|
|
32
|
+
// to_chain || recipient || amount || tx_id || vout.
|
|
33
|
+
const SELECTOR_LEN = 4;
|
|
34
|
+
const WORD_LEN = 32;
|
|
35
|
+
const WORDS = 5;
|
|
36
|
+
const PAYLOAD_LEN = SELECTOR_LEN + WORDS * WORD_LEN;
|
|
37
|
+
|
|
38
|
+
export interface IGetSuiBasculeDepositStatusParameters {
|
|
39
|
+
/** Sui RPC client. */
|
|
40
|
+
client: SuiClient;
|
|
41
|
+
/**
|
|
42
|
+
* The raw mint payload (hex, with or without `0x` prefix), see
|
|
43
|
+
* `Deposit.rawPayload`.
|
|
44
|
+
*/
|
|
45
|
+
payload: Not0xPrefixedHex;
|
|
46
|
+
/** The optional environment identifier (defaults to the SDK default). */
|
|
47
|
+
env?: Env;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Returns the Bascule deposit status for a Sui mint payload, so a claim can be
|
|
52
|
+
* pre-flighted before submitting the transaction (the on-chain
|
|
53
|
+
* `bascule::validate_withdrawal` would otherwise abort the mint).
|
|
54
|
+
*
|
|
55
|
+
* The deposit id is derived exactly as `bascule::bascule::to_deposit_id` does
|
|
56
|
+
* on-chain (and as `sui-claimer` derives it server-side):
|
|
57
|
+
*
|
|
58
|
+
* keccak256( zero32 || 0x03,0x53,0x55,0x49 || to || u64_le(amount) || tx_id || u32_le(index) )
|
|
59
|
+
*
|
|
60
|
+
* read back as a little-endian u256.
|
|
61
|
+
*/
|
|
62
|
+
export async function getBasculeDepositStatus({
|
|
63
|
+
client,
|
|
64
|
+
payload,
|
|
65
|
+
env = DEFAULT_ENV,
|
|
66
|
+
}: IGetSuiBasculeDepositStatusParameters): Promise<SuiBasculeDepositStatus> {
|
|
67
|
+
const { bascule } = getConfig(env);
|
|
68
|
+
|
|
69
|
+
// No Bascule configured for this env — treat as reported (parity with the EVM
|
|
70
|
+
// SDK, which returns REPORTED when the bascule address is the zero address).
|
|
71
|
+
if (!bascule) {
|
|
72
|
+
return SuiBasculeDepositStatus.REPORTED;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const depositId = deriveDepositId(payload);
|
|
76
|
+
const { paused, depositTableId } = await getBasculeState(client, bascule);
|
|
77
|
+
|
|
78
|
+
// While paused, validate_withdrawal aborts for every deposit, so there is no
|
|
79
|
+
// point looking up the individual deposit status.
|
|
80
|
+
if (paused) {
|
|
81
|
+
return SuiBasculeDepositStatus.PAUSED;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const status = await getDepositStatus(client, depositTableId, depositId);
|
|
85
|
+
switch (status) {
|
|
86
|
+
case DEPOSIT_REPORTED:
|
|
87
|
+
return SuiBasculeDepositStatus.REPORTED;
|
|
88
|
+
case DEPOSIT_WITHDRAWN:
|
|
89
|
+
return SuiBasculeDepositStatus.WITHDRAWN;
|
|
90
|
+
default:
|
|
91
|
+
// Not found, or an unrecognized status — not mintable.
|
|
92
|
+
return SuiBasculeDepositStatus.UNREPORTED;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface BasculeState {
|
|
97
|
+
depositTableId: string;
|
|
98
|
+
paused: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Reads the shared Bascule object and extracts the deposit-history table id and
|
|
103
|
+
* the pause flag.
|
|
104
|
+
*/
|
|
105
|
+
async function getBasculeState(
|
|
106
|
+
client: SuiClient,
|
|
107
|
+
basculeAddress: string,
|
|
108
|
+
): Promise<BasculeState> {
|
|
109
|
+
const resp = await client.getObject({
|
|
110
|
+
id: basculeAddress,
|
|
111
|
+
options: { showContent: true },
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const content = resp.data?.content;
|
|
115
|
+
if (!content || content.dataType !== 'moveObject') {
|
|
116
|
+
throw new Error(`Bascule object ${basculeAddress} has no content`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const fields = content.fields as Record<string, unknown>;
|
|
120
|
+
|
|
121
|
+
const paused = fields.mIsPaused;
|
|
122
|
+
if (typeof paused !== 'boolean') {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Bascule object ${basculeAddress} has invalid mIsPaused field`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const depositTableId = nestedString(fields, [
|
|
129
|
+
'mDepositHistory',
|
|
130
|
+
'fields',
|
|
131
|
+
'id',
|
|
132
|
+
'id',
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
return { depositTableId, paused };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Looks up a deposit id in the Bascule deposit-history table. Returns the
|
|
140
|
+
* DepositState variant name (`Reported`/`Withdrawn`) or `undefined` if there is
|
|
141
|
+
* no entry. A missing dynamic field is surfaced by the RPC as an error / empty
|
|
142
|
+
* data rather than a thrown transport error.
|
|
143
|
+
*/
|
|
144
|
+
async function getDepositStatus(
|
|
145
|
+
client: SuiClient,
|
|
146
|
+
tableId: string,
|
|
147
|
+
depositId: string,
|
|
148
|
+
): Promise<string | undefined> {
|
|
149
|
+
const resp = await client.getDynamicFieldObject({
|
|
150
|
+
parentId: tableId,
|
|
151
|
+
name: { type: 'u256', value: depositId },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const content = resp.data?.content;
|
|
155
|
+
if (!content || content.dataType !== 'moveObject') {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const fields = content.fields as Record<string, unknown>;
|
|
160
|
+
const value = fields.value;
|
|
161
|
+
if (value && typeof value === 'object' && 'variant' in value) {
|
|
162
|
+
const variant = (value as { variant: unknown }).variant;
|
|
163
|
+
if (typeof variant === 'string') {
|
|
164
|
+
return variant;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Derives the Bascule deposit id (a u256, returned as a decimal string) exactly
|
|
172
|
+
* as `bascule::bascule::to_deposit_id` does on-chain.
|
|
173
|
+
*/
|
|
174
|
+
export function deriveDepositId(payloadHex: Not0xPrefixedHex): string {
|
|
175
|
+
const raw = hexToBytes(payloadHex);
|
|
176
|
+
if (raw.length !== PAYLOAD_LEN) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Invalid mint payload length ${raw.length}, want ${PAYLOAD_LEN}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const body = raw.subarray(SELECTOR_LEN);
|
|
183
|
+
const word = (i: number) => body.subarray(i * WORD_LEN, (i + 1) * WORD_LEN);
|
|
184
|
+
// word(0) = to_chain (unused for the deposit id)
|
|
185
|
+
|
|
186
|
+
const to = word(1); // recipient, 32-byte big-endian address bytes
|
|
187
|
+
const amount = bigEndianWordToUint(word(2), 8, 'amount'); // u64
|
|
188
|
+
// tx_id = bcs::to_bytes(&txid_u256): the payload word is big-endian, BCS of a
|
|
189
|
+
// u256 is little-endian, so bascule sees the word reversed.
|
|
190
|
+
const txId = reversed(word(3));
|
|
191
|
+
const index = bigEndianWordToUint(word(4), 4, 'vout'); // u32
|
|
192
|
+
|
|
193
|
+
const preimage = concatBytes(
|
|
194
|
+
new Uint8Array(32), // CODESYNC(non-evm-prefix): 32 zero bytes
|
|
195
|
+
Uint8Array.from([0x03, 0x53, 0x55, 0x49]), // CODESYNC(sui-unique-id)
|
|
196
|
+
to,
|
|
197
|
+
uintToLeBytes(amount, 8),
|
|
198
|
+
txId,
|
|
199
|
+
uintToLeBytes(index, 4),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const digest = keccak_256(preimage);
|
|
203
|
+
|
|
204
|
+
// peel_u256 reads the digest as a little-endian u256.
|
|
205
|
+
let value = 0n;
|
|
206
|
+
for (let i = digest.length - 1; i >= 0; i--) {
|
|
207
|
+
value = (value << 8n) | BigInt(digest[i]);
|
|
208
|
+
}
|
|
209
|
+
return value.toString();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- byte helpers -----------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
215
|
+
const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
|
|
216
|
+
if (clean.length % 2 !== 0) {
|
|
217
|
+
throw new Error('Invalid payload hex: odd length');
|
|
218
|
+
}
|
|
219
|
+
// Reject any non-hex character outright, matching Go's strict
|
|
220
|
+
// hex.DecodeString. A per-byte Number.parseInt would silently accept a
|
|
221
|
+
// malformed low nibble (e.g. 'ag' -> 0x0a), yielding a wrong deposit id.
|
|
222
|
+
if (!/^[0-9a-fA-F]*$/.test(clean)) {
|
|
223
|
+
throw new Error('Invalid payload hex: non-hex characters');
|
|
224
|
+
}
|
|
225
|
+
const out = new Uint8Array(clean.length / 2);
|
|
226
|
+
for (let i = 0; i < out.length; i++) {
|
|
227
|
+
out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
228
|
+
}
|
|
229
|
+
return out;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Mirrors `u256.try_as_u64()/try_as_u32()`: the value must fit in `byteWidth`
|
|
234
|
+
* bytes, i.e. the upper bytes of the big-endian word must be zero.
|
|
235
|
+
*/
|
|
236
|
+
function bigEndianWordToUint(
|
|
237
|
+
word: Uint8Array,
|
|
238
|
+
byteWidth: number,
|
|
239
|
+
label: string,
|
|
240
|
+
): bigint {
|
|
241
|
+
for (let i = 0; i < word.length - byteWidth; i++) {
|
|
242
|
+
if (word[i] !== 0) {
|
|
243
|
+
throw new Error(`${label} does not fit in u${byteWidth * 8}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
let value = 0n;
|
|
247
|
+
for (let i = word.length - byteWidth; i < word.length; i++) {
|
|
248
|
+
value = (value << 8n) | BigInt(word[i]);
|
|
249
|
+
}
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function uintToLeBytes(value: bigint, byteWidth: number): Uint8Array {
|
|
254
|
+
const out = new Uint8Array(byteWidth);
|
|
255
|
+
let v = value;
|
|
256
|
+
for (let i = 0; i < byteWidth; i++) {
|
|
257
|
+
out[i] = Number(v & 0xffn);
|
|
258
|
+
v >>= 8n;
|
|
259
|
+
}
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function reversed(bytes: Uint8Array): Uint8Array {
|
|
264
|
+
const out = new Uint8Array(bytes.length);
|
|
265
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
266
|
+
out[i] = bytes[bytes.length - 1 - i];
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function concatBytes(...parts: Uint8Array[]): Uint8Array {
|
|
272
|
+
const total = parts.reduce((n, p) => n + p.length, 0);
|
|
273
|
+
const out = new Uint8Array(total);
|
|
274
|
+
let off = 0;
|
|
275
|
+
for (const p of parts) {
|
|
276
|
+
out.set(p, off);
|
|
277
|
+
off += p.length;
|
|
278
|
+
}
|
|
279
|
+
return out;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function nestedString(
|
|
283
|
+
fields: Record<string, unknown>,
|
|
284
|
+
keys: string[],
|
|
285
|
+
): string {
|
|
286
|
+
let cur: unknown = fields;
|
|
287
|
+
for (let i = 0; i < keys.length; i++) {
|
|
288
|
+
if (cur === null || typeof cur !== 'object') {
|
|
289
|
+
throw new Error(`key "${keys[i - 1]}" is not an object`);
|
|
290
|
+
}
|
|
291
|
+
cur = (cur as Record<string, unknown>)[keys[i]];
|
|
292
|
+
if (cur === undefined) {
|
|
293
|
+
throw new Error(`missing key "${keys[i]}"`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (typeof cur !== 'string') {
|
|
297
|
+
throw new Error(`key "${keys[keys.length - 1]}" is not a string`);
|
|
298
|
+
}
|
|
299
|
+
return cur;
|
|
300
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './getBasculeDepositStatus';
|
|
@@ -34,9 +34,7 @@ export async function prepareCoinsTransaction({
|
|
|
34
34
|
const decimals = coinMetadata?.decimals ?? LBTC_DECIMALS;
|
|
35
35
|
|
|
36
36
|
const unstakeAmount = BigInt(
|
|
37
|
-
amount
|
|
38
|
-
.multipliedBy(new BigNumber(10).pow(decimals))
|
|
39
|
-
.toString(10),
|
|
37
|
+
amount.multipliedBy(new BigNumber(10).pow(decimals)).toString(10),
|
|
40
38
|
);
|
|
41
39
|
|
|
42
40
|
const selectedCoins = [] as CoinStruct[];
|
|
@@ -49,7 +47,7 @@ export async function prepareCoinsTransaction({
|
|
|
49
47
|
} else if (selectedAmount + BigInt(coin.balance) === unstakeAmount) {
|
|
50
48
|
selectedCoins.push(coin);
|
|
51
49
|
|
|
52
|
-
const coinObjects = selectedCoins.map(coin =>
|
|
50
|
+
const coinObjects = selectedCoins.map((coin) =>
|
|
53
51
|
transaction.object(coin.coinObjectId),
|
|
54
52
|
);
|
|
55
53
|
|
|
@@ -73,7 +71,7 @@ export async function prepareCoinsTransaction({
|
|
|
73
71
|
|
|
74
72
|
transaction.mergeCoins(
|
|
75
73
|
splitCoin,
|
|
76
|
-
selectedCoins.map(coin => transaction.object(coin.coinObjectId)),
|
|
74
|
+
selectedCoins.map((coin) => transaction.object(coin.coinObjectId)),
|
|
77
75
|
);
|
|
78
76
|
|
|
79
77
|
return transaction.object(splitCoin);
|
|
@@ -30,16 +30,18 @@ const WALLETS_FORCE_V1 = ['OKX Wallet', 'OKX'];
|
|
|
30
30
|
* Check if we should force V1 for this wallet.
|
|
31
31
|
* Some wallets advertise V2 support but don't properly handle the new Transaction class.
|
|
32
32
|
*/
|
|
33
|
-
function shouldForceV1(
|
|
33
|
+
function shouldForceV1(
|
|
34
|
+
wallet: WalletWithFeatures<SuiSignTransactionFeature>,
|
|
35
|
+
): boolean {
|
|
34
36
|
const walletName = wallet.name || '';
|
|
35
|
-
return WALLETS_FORCE_V1.some(name =>
|
|
36
|
-
walletName.toLowerCase().includes(name.toLowerCase())
|
|
37
|
+
return WALLETS_FORCE_V1.some((name) =>
|
|
38
|
+
walletName.toLowerCase().includes(name.toLowerCase()),
|
|
37
39
|
);
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* Unstake LBTC.
|
|
42
|
-
*
|
|
44
|
+
*
|
|
43
45
|
* Supports both new (sui:signTransaction) and old (sui:signTransactionBlock) wallet interfaces.
|
|
44
46
|
* Some wallets (like OKX) advertise V2 support but fail with the new Transaction class,
|
|
45
47
|
* so we force V1 for known problematic wallets.
|
|
@@ -58,11 +60,11 @@ export async function unstakeLBTC({
|
|
|
58
60
|
// Cast to any to access both V1 and V2 features
|
|
59
61
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
60
62
|
const walletFeatures = (wallet as any).features || {};
|
|
61
|
-
|
|
63
|
+
|
|
62
64
|
const walletHasV2 = !!walletFeatures[SIGN_TRANSACTION_V2_FEATURE];
|
|
63
65
|
const walletHasV1 = !!walletFeatures[SIGN_TRANSACTION_V1_FEATURE];
|
|
64
66
|
const forceV1 = shouldForceV1(wallet);
|
|
65
|
-
|
|
67
|
+
|
|
66
68
|
// Use V2 only if available AND wallet is not in the force-V1 list
|
|
67
69
|
const useV2 = walletHasV2 && !forceV1;
|
|
68
70
|
|
|
@@ -75,7 +77,10 @@ export async function unstakeLBTC({
|
|
|
75
77
|
});
|
|
76
78
|
|
|
77
79
|
const scriptPubKey = Array.from(
|
|
78
|
-
Buffer.from(
|
|
80
|
+
Buffer.from(
|
|
81
|
+
(await getOutputScript(btcAddress, env)).replace(/^0x/, ''),
|
|
82
|
+
'hex',
|
|
83
|
+
),
|
|
79
84
|
);
|
|
80
85
|
|
|
81
86
|
transaction.moveCall({
|
|
@@ -114,7 +119,9 @@ export async function unstakeLBTC({
|
|
|
114
119
|
// Use V1 signTransactionBlock (OKX, and other wallets with V2 issues)
|
|
115
120
|
transaction.setSender(walletAccount.address);
|
|
116
121
|
|
|
117
|
-
const signedTransaction = await walletFeatures[
|
|
122
|
+
const signedTransaction = await walletFeatures[
|
|
123
|
+
SIGN_TRANSACTION_V1_FEATURE
|
|
124
|
+
].signTransactionBlock({
|
|
118
125
|
chain: chainId,
|
|
119
126
|
transactionBlock: transaction,
|
|
120
127
|
account: walletAccount,
|