Skip to content

Commit 0b20ea0

Browse files
kaladinlightclaude
andauthored
feat(swapper): near intents swaps from aptos (#12807)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 777be22 commit 0b20ea0

27 files changed

Lines changed: 418 additions & 45 deletions

File tree

‎packages/chain-adapters/src/aptos/AptosChainAdapter.test.ts‎

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
11
import type * as AptosSdkActual from '@aptos-labs/ts-sdk'
2-
import { Deserializer, RawTransaction } from '@aptos-labs/ts-sdk'
2+
import {
3+
AccountAddress,
4+
ChainId,
5+
Deserializer,
6+
generateRawTransaction,
7+
Hex,
8+
RawTransaction,
9+
} from '@aptos-labs/ts-sdk'
310
import { aptosAssetId } from '@shapeshiftoss/caip'
411
import { TransferType, TxStatus } from '@shapeshiftoss/unchained-client'
512
import { beforeEach, describe, expect, it, vi } from 'vitest'
613

714
import { ValidAddressResultType } from '../types'
815
import { ChainAdapter } from './AptosChainAdapter'
16+
import { getSerializedTransferPayload } from './utils'
917

1018
vi.mock('@aptos-labs/ts-sdk', async () => {
1119
// keep the real bcs and key classes, mock only the network client
@@ -39,6 +47,7 @@ vi.mock('@aptos-labs/ts-sdk', async () => {
3947
}
4048
}),
4149
AptosConfig: vi.fn(),
50+
generateRawTransaction: vi.fn(),
4251
Network: { MAINNET: 'mainnet' },
4352
}
4453
})
@@ -247,6 +256,44 @@ describe('AptosChainAdapter', () => {
247256
})
248257
})
249258

259+
describe('buildCustomApiTx', () => {
260+
it.each([
261+
['APT', undefined],
262+
['a coin', LEGACY_USDC],
263+
['a fungible asset', USDC_FA],
264+
])('signs the serialized %s payload at the quoted gas', async (_, contractAddress) => {
265+
const payload = getSerializedTransferPayload(ADDR, '1000', contractAddress)
266+
267+
vi.mocked(generateRawTransaction).mockImplementationOnce(args =>
268+
Promise.resolve(
269+
new RawTransaction(
270+
AccountAddress.ONE,
271+
7n,
272+
args.payload as RawTransaction['payload'],
273+
2000n,
274+
100n,
275+
99n,
276+
new ChainId(1),
277+
),
278+
),
279+
)
280+
281+
const { rawTransactionBytes } = await adapter.buildCustomApiTx({
282+
from: SENDER,
283+
accountNumber: 0,
284+
payload,
285+
gasUnitPrice: '150',
286+
maxGasAmount: '4639',
287+
})
288+
289+
const raw = RawTransaction.deserialize(new Deserializer(rawTransactionBytes))
290+
291+
expect(raw.max_gas_amount).toBe(4639n)
292+
expect(raw.gas_unit_price).toBe(150n)
293+
expect(Hex.fromHexInput(raw.payload.bcsToBytes()).toString()).toBe(payload)
294+
})
295+
})
296+
250297
describe('getFeeData', () => {
251298
const getFeeData = () =>
252299
adapter.getFeeData({
@@ -296,7 +343,7 @@ describe('AptosChainAdapter', () => {
296343
})
297344
})
298345

299-
it('simulates apt sends with 1 octa so a near full balance amount still gets fees', async () => {
346+
it('simulates the given value so a sender short of it fails', async () => {
300347
client.transaction.simulate.simple.mockResolvedValueOnce([simulation])
301348

302349
await adapter.getFeeData({
@@ -307,10 +354,18 @@ describe('AptosChainAdapter', () => {
307354

308355
expect(client.transaction.build.simple.mock.calls[0][0].data.functionArguments).toEqual([
309356
ADDR,
310-
1n,
357+
198633150n,
311358
])
312359
})
313360

361+
it('simulates with a fee payer so a sender without apt still gets fees', async () => {
362+
client.transaction.simulate.simple.mockResolvedValueOnce([simulation])
363+
364+
await getFeeData()
365+
366+
expect(client.transaction.build.simple.mock.calls[0][0].withFeePayer).toBe(true)
367+
})
368+
314369
it.each([
315370
['fails', { success: false, gas_used: '0', vm_status: 'INSUFFICIENT_BALANCE' }],
316371
['has no fee statement', { success: true, events: [] }],

‎packages/chain-adapters/src/aptos/AptosChainAdapter.ts‎

Lines changed: 62 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Deserializer,
77
Ed25519PublicKey,
88
Ed25519Signature,
9+
generateRawTransaction,
910
Hex,
1011
isPendingTransactionResponse,
1112
isUserTransactionResponse,
@@ -15,6 +16,7 @@ import {
1516
RawTransaction,
1617
SignedTransaction,
1718
TransactionAuthenticatorEd25519,
19+
TransactionPayload,
1820
} from '@aptos-labs/ts-sdk'
1921
import type { AssetId, ChainId } from '@shapeshiftoss/caip'
2022
import {
@@ -48,9 +50,14 @@ import type {
4850
} from '../types'
4951
import { ChainAdapterDisplayName, ValidAddressResultType } from '../types'
5052
import { toAddressNList, verifyLedgerAppOpen } from '../utils'
51-
import { APT_COIN_TYPE, FEE_STATEMENT_EVENT_TYPE, INDEXER_PAGE_LIMIT } from './constants'
53+
import {
54+
APT_COIN_TYPE,
55+
FEE_STATEMENT_EVENT_TYPE,
56+
INDEXER_PAGE_LIMIT,
57+
TX_EXPIRY_SECS,
58+
} from './constants'
5259
import { parseAptosTx } from './parser'
53-
import type { Token } from './types'
60+
import type { BuildCustomApiTxInput, Token } from './types'
5461
import { getBalancePayload, getDecimalsPayload, getTransferPayload } from './utils'
5562

5663
export interface ChainAdapterArgs {
@@ -77,6 +84,7 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.AptosMainnet> {
7784
fullnode: `${args.rpcUrl}/v1`,
7885
indexer: `${args.indexerUrl}/v1/graphql`,
7986
clientConfig: args.apiKey ? { API_KEY: args.apiKey } : undefined,
87+
transactionGenerationConfig: { defaultTxnExpirySecFromNow: TX_EXPIRY_SECS },
8088
}),
8189
)
8290
}
@@ -272,31 +280,62 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.AptosMainnet> {
272280
data: getTransferPayload(to, value, contractAddress),
273281
})
274282

275-
// the sdk floors max gas at 2000 units, which would reserve far more apt than the fee
276-
const { sender, sequence_number, payload, expiration_timestamp_secs, chain_id } =
277-
rawTransaction
278-
279-
const transaction = new RawTransaction(
280-
sender,
281-
sequence_number,
282-
payload,
283-
BigInt(maxGasAmount),
284-
BigInt(gasUnitPrice),
285-
expiration_timestamp_secs,
286-
chain_id,
287-
)
283+
return this.toSignTx(rawTransaction, { accountNumber, gasUnitPrice, maxGasAmount })
284+
} catch (err) {
285+
return ErrorHandler(err, {
286+
translation: 'chainAdapters.errors.buildTransaction',
287+
})
288+
}
289+
}
288290

289-
return {
290-
addressNList: toAddressNList(this.getBip44Params({ accountNumber })),
291-
rawTransactionBytes: transaction.bcsToBytes(),
292-
}
291+
async buildCustomApiTx(
292+
input: BuildCustomApiTxInput,
293+
): Promise<SignTx<KnownChainIds.AptosMainnet>> {
294+
try {
295+
const { from, accountNumber, payload, gasUnitPrice, maxGasAmount } = input
296+
297+
const rawTransaction = await generateRawTransaction({
298+
aptosConfig: this.client.config,
299+
sender: from,
300+
payload: TransactionPayload.deserialize(
301+
new Deserializer(Hex.fromHexInput(payload).toUint8Array()),
302+
),
303+
})
304+
305+
return this.toSignTx(rawTransaction, { accountNumber, gasUnitPrice, maxGasAmount })
293306
} catch (err) {
294307
return ErrorHandler(err, {
295308
translation: 'chainAdapters.errors.buildTransaction',
296309
})
297310
}
298311
}
299312

313+
private toSignTx(
314+
rawTransaction: RawTransaction,
315+
{
316+
accountNumber,
317+
gasUnitPrice,
318+
maxGasAmount,
319+
}: Pick<BuildCustomApiTxInput, 'accountNumber' | 'gasUnitPrice' | 'maxGasAmount'>,
320+
): SignTx<KnownChainIds.AptosMainnet> {
321+
const { sender, sequence_number, payload, expiration_timestamp_secs, chain_id } = rawTransaction
322+
323+
const transaction = new RawTransaction(
324+
sender,
325+
sequence_number,
326+
payload,
327+
BigInt(maxGasAmount),
328+
BigInt(gasUnitPrice),
329+
expiration_timestamp_secs,
330+
chain_id,
331+
)
332+
333+
return {
334+
addressNList: toAddressNList(this.getBip44Params({ accountNumber })),
335+
rawTransactionBytes: transaction.bcsToBytes(),
336+
}
337+
}
338+
300339
async buildSendTransaction(input: BuildSendTxInput<KnownChainIds.AptosMainnet>): Promise<{
301340
txToSign: SignTx<KnownChainIds.AptosMainnet>
302341
}> {
@@ -388,23 +427,18 @@ export class ChainAdapter implements IChainAdapter<KnownChainIds.AptosMainnet> {
388427
const { to, value, chainSpecific } = input
389428
const { from, contractAddress } = chainSpecific
390429

391-
// gas doesn't depend on the amount, and an apt amount near the balance can't cover gas in simulation
392-
const simulatedValue = contractAddress ? value : '1'
393-
394430
const [{ gas_estimate, prioritized_gas_estimate, deprioritized_gas_estimate }, transaction] =
395431
await Promise.all([
396432
this.client.getGasPriceEstimation(),
397433
this.client.transaction.build.simple({
398434
sender: from,
399-
data: getTransferPayload(to, simulatedValue, contractAddress),
435+
data: getTransferPayload(to, value, contractAddress),
436+
// sets the zero address as fee payer, which simulation accepts without checking anyone's gas balance
437+
withFeePayer: true,
400438
}),
401439
])
402440

403-
// caps simulated gas at what the sender can afford, so the default cap can't fail the simulation
404-
const [sim] = await this.client.transaction.simulate.simple({
405-
transaction,
406-
options: { estimateGasUnitPrice: true, estimateMaxGasAmount: true },
407-
})
441+
const [sim] = await this.client.transaction.simulate.simple({ transaction })
408442

409443
if (!sim?.success) throw new Error(`transaction simulation failed: ${sim?.vm_status}`)
410444

‎packages/chain-adapters/src/aptos/constants.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export const FEE_STATEMENT_EVENT_TYPE = '0x1::transaction_fee::FeeStatement'
77

88
export const INDEXER_PAGE_LIMIT = 100
99

10+
// the sdk default of 20s starts at build, so it can run out while a slow signer is confirming
11+
export const TX_EXPIRY_SECS = 300
12+
1013
export const COIN_TRANSFER_FUNCTIONS = [
1114
'0x1::aptos_account::transfer',
1215
'0x1::aptos_account::transfer_coins',

‎packages/chain-adapters/src/aptos/types.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export type BuildTxInput = FeeData & {
2222
contractAddress: string | undefined
2323
}
2424

25+
export type BuildCustomApiTxInput = FeeData & {
26+
from: string
27+
accountNumber: number
28+
payload: string
29+
}
30+
2531
export type GetFeeDataInput = {
2632
from: string
2733
contractAddress: string | undefined

‎packages/chain-adapters/src/aptos/utils.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { InputEntryFunctionData, InputViewFunctionData } from '@aptos-labs/ts-sdk'
2-
import { AccountAddress } from '@aptos-labs/ts-sdk'
1+
import type { InputEntryFunctionDataWithABI, InputViewFunctionData } from '@aptos-labs/ts-sdk'
2+
import { AccountAddress, generateTransactionPayloadWithABI, Hex } from '@aptos-labs/ts-sdk'
33
import { ASSET_NAMESPACE, getAptosAssetNamespace } from '@shapeshiftoss/caip'
44

55
import { APT_COIN_TYPE, COIN_TRANSFER_ABI, FA_TRANSFER_ABI } from './constants'
@@ -20,7 +20,7 @@ export const getTransferPayload = (
2020
to: string,
2121
value: string,
2222
contractAddress: string | undefined,
23-
): InputEntryFunctionData => {
23+
): InputEntryFunctionDataWithABI => {
2424
if (contractAddress && isFungibleAssetReference(contractAddress)) {
2525
return {
2626
function: '0x1::primary_fungible_store::transfer',
@@ -38,6 +38,16 @@ export const getTransferPayload = (
3838
}
3939
}
4040

41+
export const getSerializedTransferPayload = (
42+
to: string,
43+
value: string,
44+
contractAddress: string | undefined,
45+
): string => {
46+
const payload = generateTransactionPayloadWithABI(getTransferPayload(to, value, contractAddress))
47+
48+
return Hex.fromHexInput(payload.bcsToBytes()).toString()
49+
}
50+
4151
export const getDecimalsPayload = (assetType: string): InputViewFunctionData => {
4252
if (isFungibleAssetReference(assetType)) {
4353
return {

‎packages/public-api/.env.example‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ NEAR_NODE_URL=https://rpc.fastnear.com
5757
NEAR_NODE_URL_FALLBACK_1=https://near.drpc.org
5858
NEAR_NODE_URL_FALLBACK_2=https://rpc.mainnet.near.org
5959
FASTNEAR_API_URL=https://api.fastnear.com
60+
APTOS_NODE_URL=https://fullnode.mainnet.aptoslabs.com
61+
APTOS_INDEXER_URL=https://api.mainnet.aptoslabs.com
6062

6163
# Midgard URLs
6264
THORCHAIN_MIDGARD_URL=https://dev-api.thorchain.shapeshift.com/midgard/v2

‎packages/public-api/src/env.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ const envSchema = z.object({
6363
NEAR_NODE_URL_FALLBACK_1: z.union([url, z.literal('')]).default(''),
6464
NEAR_NODE_URL_FALLBACK_2: z.union([url, z.literal('')]).default(''),
6565
FASTNEAR_API_URL: url,
66+
APTOS_NODE_URL: url,
67+
APTOS_INDEXER_URL: url,
6668

6769
// Midgard URLs
6870
THORCHAIN_MIDGARD_URL: url,

‎packages/public-api/src/swapperDeps.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,13 +282,19 @@ const starknetAdapter = new adapters.starknet.ChainAdapter({
282282
),
283283
})
284284

285+
const aptosAdapter = new adapters.aptos.ChainAdapter({
286+
rpcUrl: env.APTOS_NODE_URL,
287+
indexerUrl: env.APTOS_INDEXER_URL,
288+
})
289+
285290
const otherAdaptersById: Record<ChainId, unknown> = {
286291
[KnownChainIds.SolanaMainnet]: solanaAdapter,
287292
[KnownChainIds.TronMainnet]: tronAdapter,
288293
[KnownChainIds.SuiMainnet]: suiAdapter,
289294
[KnownChainIds.TonMainnet]: tonAdapter,
290295
[KnownChainIds.NearMainnet]: nearAdapter,
291296
[KnownChainIds.StarknetMainnet]: starknetAdapter,
297+
[KnownChainIds.AptosMainnet]: aptosAdapter,
292298
}
293299

294300
export const getSwapperDeps = (): SwapperDeps => ({
@@ -325,5 +331,6 @@ export const getSwapperDeps = (): SwapperDeps => ({
325331
assertGetNearChainAdapter: () => nearAdapter,
326332
assertGetStarknetChainAdapter: () => starknetAdapter,
327333
assertGetTonChainAdapter: () => tonAdapter,
334+
assertGetAptosChainAdapter: () => aptosAdapter,
328335
fetchIsSmartContractAddressQuery: () => Promise.resolve(false),
329336
})

‎packages/swapper/src/swappers/ButterSwap/swapperApi/getTradeQuote.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ describe('getTradeQuote', () => {
6666
assertGetNearChainAdapter: () => vi.fn() as any,
6767
assertGetStarknetChainAdapter: () => vi.fn() as any,
6868
assertGetTonChainAdapter: () => vi.fn() as any,
69+
assertGetAptosChainAdapter: () => vi.fn() as any,
6970
config: {
7071
VITE_BUTTERSWAP_CLIENT_ID: 'test',
7172
} as any,

‎packages/swapper/src/swappers/ButterSwap/swapperApi/getTradeRate.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ describe('getTradeRate', () => {
4242
assertGetNearChainAdapter: () => vi.fn() as any,
4343
assertGetStarknetChainAdapter: () => vi.fn() as any,
4444
assertGetTonChainAdapter: () => vi.fn() as any,
45+
assertGetAptosChainAdapter: () => vi.fn() as any,
4546
config: {
4647
VITE_BUTTERSWAP_CLIENT_ID: 'test',
4748
} as any,

0 commit comments

Comments
 (0)