@aboutcircles/sdk-transfers 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.
@@ -0,0 +1,79 @@
1
+ import type { Address, AdvancedTransferOptions } from '@aboutcircles/sdk-types';
2
+ import type { Core } from '@aboutcircles/sdk-core';
3
+ /**
4
+ * TransferBuilder constructs transfer transactions without executing them
5
+ * Handles pathfinding, wrapped token unwrapping/wrapping, and flow matrix construction
6
+ */
7
+ export declare class TransferBuilder {
8
+ private core;
9
+ private rpc;
10
+ constructor(core: Core);
11
+ /**
12
+ * Construct an advanced transfer transaction
13
+ * Returns the list of transactions to execute without executing them
14
+ *
15
+ * @param from Sender address
16
+ * @param to Recipient address
17
+ * @param amount Amount to transfer (in atto-circles)
18
+ * @param options Advanced transfer options
19
+ * @returns Array of transactions to execute in order
20
+ */
21
+ constructAdvancedTransfer(from: Address, to: Address, amount: number | bigint, options?: AdvancedTransferOptions): Promise<Array<{
22
+ to: Address;
23
+ data: `0x${string}`;
24
+ value: bigint;
25
+ }>>;
26
+ /**
27
+ * Construct a replenish transaction to convert wrapped/other tokens into unwrapped personal CRC
28
+ * This uses pathfinder to find the best way to convert available tokens (including wrapped tokens)
29
+ * into the sender's own unwrapped ERC1155 personal CRC tokens
30
+ *
31
+ * @param avatarAddress The avatar address to replenish (convert tokens to their personal CRC)
32
+ * @param options Optional pathfinding options
33
+ * @returns Array of transactions to execute in order to perform the replenish
34
+ */
35
+ constructReplenish(avatarAddress: Address, options?: Omit<AdvancedTransferOptions, 'txData'>): Promise<Array<{
36
+ to: Address;
37
+ data: `0x${string}`;
38
+ value: bigint;
39
+ }>>;
40
+ /**
41
+ * Fetches token balances and creates a map for quick lookup
42
+ *
43
+ * @param from Source avatar address
44
+ * @returns Map of token address to balance (in static units)
45
+ */
46
+ private _getTokenBalanceMap;
47
+ /**
48
+ * Creates unwrap transaction calls for demurraged ERC20 wrapped tokens
49
+ * Unwraps only the exact amount used in the path
50
+ *
51
+ * @param wrappedTokensInPath Map of wrapped token addresses to [amount used in path, type]
52
+ * @returns Array of unwrap transaction calls for demurraged tokens
53
+ */
54
+ private _createDemurragedUnwrapCalls;
55
+ /**
56
+ * Creates unwrap and wrap transaction calls for inflationary ERC20 wrapped tokens
57
+ * Unwraps the entire balance, then wraps back leftover tokens after transfer
58
+ *
59
+ * @param wrappedTokensInPath Map of wrapped token addresses to [amount used in path, type]
60
+ * @param tokenInfoMap Map of token addresses to TokenInfo
61
+ * @param balanceMap Map of token address to balance
62
+ * @returns Object containing unwrap and wrap transaction calls for inflationary tokens
63
+ */
64
+ private _createInflationaryUnwrapAndWrapCalls;
65
+ /**
66
+ * Helper method to truncate amount to 6 decimals
67
+ */
68
+ private _truncateToSixDecimals;
69
+ /**
70
+ * Get default token exclusion list for transfers to group mint handlers
71
+ * If the recipient is a group mint handler, exclude the group token and its wrappers
72
+ *
73
+ * @param to Recipient address
74
+ * @param excludeFromTokens Existing token exclusion list
75
+ * @returns Complete token exclusion list, or undefined if empty
76
+ */
77
+ private _getDefaultTokenExcludeList;
78
+ }
79
+ //# sourceMappingURL=TransferBuilder.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TransferBuilder.d.ts","sourceRoot":"","sources":["../src/TransferBuilder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,uBAAuB,EAAqB,MAAM,yBAAyB,CAAC;AACnG,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,wBAAwB,CAAC;AAYnD;;;GAGG;AACH,qBAAa,eAAe;IAC1B,OAAO,CAAC,IAAI,CAAO;IACnB,OAAO,CAAC,GAAG,CAAa;gBAEZ,IAAI,EAAE,IAAI;IAKtB;;;;;;;;;OASG;IACG,yBAAyB,CAC7B,IAAI,EAAE,OAAO,EACb,EAAE,EAAE,OAAO,EACX,MAAM,EAAE,MAAM,GAAG,MAAM,EACvB,OAAO,CAAC,EAAE,uBAAuB,GAChC,OAAO,CAAC,KAAK,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE,KAAK,MAAM,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAgLtE;;;;;;;;OAQG;IACG,kBAAkB,CACtB,aAAa,EAAE,OAAO,EACtB,OAAO,CAAC,EAAE,IAAI,CAAC,uBAAuB,EAAE,QAAQ,CAAC,GAChD,OAAO,CAAC,KAAK,CAAC;QAAE,EAAE,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE,KAAK,MAAM,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAetE;;;;;OAKG;YACW,mBAAmB;IAUjC;;;;;;OAMG;IACH,OAAO,CAAC,4BAA4B;IAkCpC;;;;;;;;OAQG;IACH,OAAO,CAAC,qCAAqC;IAmE7C;;OAEG;IACH,OAAO,CAAC,sBAAsB;IAM9B;;;;;;;OAOG;YACW,2BAA2B;CAoC1C"}
@@ -0,0 +1,333 @@
1
+ import { createFlowMatrix as createFlowMatrixUtil, getTokenInfoMapFromPath, getWrappedTokensFromPath, replaceWrappedTokensWithAvatars, } from '@aboutcircles/sdk-pathfinder';
2
+ import { CirclesRpc } from '@aboutcircles/sdk-rpc';
3
+ import { bytesToHex, CirclesConverter, encodeFunctionData, ZERO_ADDRESS } from '@aboutcircles/sdk-utils';
4
+ import { InflationaryCirclesContract, DemurrageCirclesContract, CirclesType } from '@aboutcircles/sdk-core';
5
+ import { TransferError } from './errors';
6
+ /**
7
+ * TransferBuilder constructs transfer transactions without executing them
8
+ * Handles pathfinding, wrapped token unwrapping/wrapping, and flow matrix construction
9
+ */
10
+ export class TransferBuilder {
11
+ core;
12
+ rpc;
13
+ constructor(core) {
14
+ this.core = core;
15
+ this.rpc = new CirclesRpc(core.config.circlesRpcUrl);
16
+ }
17
+ /**
18
+ * Construct an advanced transfer transaction
19
+ * Returns the list of transactions to execute without executing them
20
+ *
21
+ * @param from Sender address
22
+ * @param to Recipient address
23
+ * @param amount Amount to transfer (in atto-circles)
24
+ * @param options Advanced transfer options
25
+ * @returns Array of transactions to execute in order
26
+ */
27
+ async constructAdvancedTransfer(from, to, amount, options) {
28
+ // Normalize addresses
29
+ const fromAddr = from.toLowerCase();
30
+ const toAddr = to.toLowerCase();
31
+ const amountBigInt = BigInt(amount);
32
+ // @todo move logic to separate function
33
+ // Optimization: Check if this is a self-transfer unwrap operation
34
+ // If sender == recipient and we have exactly one fromToken and one toToken,
35
+ // we can check if it's an unwrap operation and skip pathfinding
36
+ if (fromAddr === toAddr &&
37
+ options?.fromTokens?.length === 1 &&
38
+ options?.toTokens?.length === 1) {
39
+ const fromTokenAddr = options.fromTokens[0];
40
+ const toTokenAddr = options.toTokens[0];
41
+ // Use lift contract to check if fromToken is a wrapper and determine its type
42
+ const [demurragedWrapper, inflationaryWrapper] = await Promise.all([
43
+ this.core.liftERC20.erc20Circles(CirclesType.Demurrage, toTokenAddr),
44
+ this.core.liftERC20.erc20Circles(CirclesType.Inflation, toTokenAddr)
45
+ ]);
46
+ // Check if fromToken is a demurraged wrapper for the toToken avatar
47
+ if (fromTokenAddr.toLowerCase() === demurragedWrapper.toLowerCase() &&
48
+ demurragedWrapper !== ZERO_ADDRESS) {
49
+ // Use demurraged wrapper contract to unwrap
50
+ const wrapper = new DemurrageCirclesContract({
51
+ address: fromTokenAddr,
52
+ rpcUrl: this.core.config.circlesRpcUrl
53
+ });
54
+ const unwrapTx = wrapper.unwrap(amountBigInt);
55
+ return [{
56
+ to: unwrapTx.to,
57
+ data: unwrapTx.data,
58
+ value: unwrapTx.value ?? 0n
59
+ }];
60
+ }
61
+ // Check if fromToken is an inflationary wrapper for the toToken avatar
62
+ if (fromTokenAddr.toLowerCase() === inflationaryWrapper.toLowerCase() &&
63
+ inflationaryWrapper !== ZERO_ADDRESS) {
64
+ // Use inflationary wrapper contract to unwrap
65
+ const wrapper = new InflationaryCirclesContract({
66
+ address: fromTokenAddr,
67
+ rpcUrl: this.core.config.circlesRpcUrl
68
+ });
69
+ // Convert demurraged amount to static atto circles for inflationary unwrap
70
+ const unwrapAmount = CirclesConverter.attoCirclesToAttoStaticCircles(amountBigInt);
71
+ const unwrapTx = wrapper.unwrap(unwrapAmount);
72
+ return [{
73
+ to: unwrapTx.to,
74
+ data: unwrapTx.data,
75
+ value: unwrapTx.value ?? 0n
76
+ }];
77
+ }
78
+ }
79
+ // Truncate to 6 decimals for precision
80
+ const truncatedAmount = this._truncateToSixDecimals(amountBigInt);
81
+ // Get default token exclude list if sending to a group mint handler
82
+ const completeExcludeFromTokens = await this._getDefaultTokenExcludeList(toAddr, options?.excludeFromTokens);
83
+ // Update options with complete exclude list
84
+ const pathfindingOptions = {
85
+ ...options,
86
+ ...(completeExcludeFromTokens ? { excludeFromTokens: completeExcludeFromTokens } : {}),
87
+ };
88
+ let path = await this.rpc.pathfinder.findPath({
89
+ from: fromAddr,
90
+ to: toAddr,
91
+ targetFlow: truncatedAmount,
92
+ ...pathfindingOptions,
93
+ });
94
+ // Check if path is valid
95
+ if (!path.transfers || path.transfers.length === 0) {
96
+ throw TransferError.noPathFound(fromAddr, toAddr);
97
+ }
98
+ // Check if pathfinder found enough tokens for the requested amount
99
+ if (path.maxFlow < truncatedAmount) {
100
+ throw TransferError.insufficientBalance(truncatedAmount, path.maxFlow, fromAddr, toAddr);
101
+ }
102
+ // Get token info for all tokens in the path using pathfinder utility
103
+ // @dev returning a Map<string, TokenInfo>
104
+ const tokenInfoMap = await getTokenInfoMapFromPath(fromAddr, this.core.config.circlesRpcUrl, path);
105
+ // Get wrapped tokens found in the path with their amounts and types
106
+ // @dev returning a Record<string (wrapperAddress), [bigint (amount used in path), string (type)]>
107
+ const wrappedTokensInPath = getWrappedTokensFromPath(path, tokenInfoMap);
108
+ // @todo maybe there is an easier way to check if there are wrapped tokens
109
+ const hasWrappedTokens = Object.keys(wrappedTokensInPath).length > 0;
110
+ // Validate that wrapped tokens are enabled if they're needed
111
+ if (hasWrappedTokens && !options?.useWrappedBalances) {
112
+ throw TransferError.wrappedTokensRequired();
113
+ }
114
+ let unwrapCalls = [];
115
+ let wrapCalls = [];
116
+ if (hasWrappedTokens) {
117
+ // Fetch token balances once for both unwrap and wrap operations
118
+ const balanceMap = await this._getTokenBalanceMap(fromAddr);
119
+ // Create unwrap calls for demurraged tokens (unwrap exact amount used in path)
120
+ const demurragedUnwrapCalls = this._createDemurragedUnwrapCalls(wrappedTokensInPath);
121
+ // Create unwrap and wrap calls for inflationary tokens
122
+ // Unwrap entire balance, then wrap back leftovers after transfer
123
+ const { unwrapCalls: inflationaryUnwrapCalls, wrapCalls: inflationaryWrapCalls } = this._createInflationaryUnwrapAndWrapCalls(wrappedTokensInPath, tokenInfoMap, balanceMap);
124
+ // Combine all unwrap calls
125
+ unwrapCalls = [...demurragedUnwrapCalls, ...inflationaryUnwrapCalls];
126
+ wrapCalls = inflationaryWrapCalls;
127
+ // Replace wrapped token addresses with avatar addresses in the path
128
+ path = replaceWrappedTokensWithAvatars(path, tokenInfoMap);
129
+ }
130
+ // Create flow matrix from the (possibly rewritten) path
131
+ const flowMatrix = createFlowMatrixUtil(fromAddr, toAddr, path.maxFlow, path.transfers);
132
+ // If txData is provided, attach it to the streams
133
+ if (options?.txData && flowMatrix.streams.length > 0) {
134
+ flowMatrix.streams[0].data = options.txData;
135
+ }
136
+ // Convert Uint8Array data to hex strings for ABI encoding
137
+ const streamsWithHexData = flowMatrix.streams.map((stream) => ({
138
+ sourceCoordinate: stream.sourceCoordinate,
139
+ flowEdgeIds: stream.flowEdgeIds,
140
+ data: stream.data instanceof Uint8Array ? bytesToHex(stream.data) : stream.data,
141
+ }));
142
+ // Create the operateFlowMatrix transaction
143
+ const operateFlowMatrixTx = this.core.hubV2.operateFlowMatrix(flowMatrix.flowVertices, flowMatrix.flowEdges, streamsWithHexData, flowMatrix.packedCoordinates);
144
+ // Check if self-approval is needed
145
+ // If the check fails (e.g., network error), we'll include the approval anyway to be safe
146
+ let isApproved = false;
147
+ try {
148
+ isApproved = await this.core.hubV2.isApprovedForAll(fromAddr, fromAddr);
149
+ }
150
+ catch (error) {
151
+ // If checking approval fails, assume not approved and include the approval transaction
152
+ console.warn('Failed to check approval status, including approval transaction:', error);
153
+ }
154
+ // Assemble all transactions in strict order:
155
+ // 1. Self-approval (only if not already approved)
156
+ // 2. All unwraps
157
+ // 3. operateFlowMatrix
158
+ // 4. All wraps (for leftover inflationary tokens)
159
+ const allTransactions = [
160
+ ...(isApproved ? [] : [this.core.hubV2.setApprovalForAll(fromAddr, true)]),
161
+ ...unwrapCalls,
162
+ operateFlowMatrixTx,
163
+ ...wrapCalls,
164
+ ];
165
+ return allTransactions;
166
+ }
167
+ /**
168
+ * Construct a replenish transaction to convert wrapped/other tokens into unwrapped personal CRC
169
+ * This uses pathfinder to find the best way to convert available tokens (including wrapped tokens)
170
+ * into the sender's own unwrapped ERC1155 personal CRC tokens
171
+ *
172
+ * @param avatarAddress The avatar address to replenish (convert tokens to their personal CRC)
173
+ * @param options Optional pathfinding options
174
+ * @returns Array of transactions to execute in order to perform the replenish
175
+ */
176
+ async constructReplenish(avatarAddress, options) {
177
+ // @todo Implement replenish functionality
178
+ // This should:
179
+ // 1. Find maximum flow from avatar to itself targeting personal tokens
180
+ // 2. Handle wrapped token unwrapping similar to constructAdvancedTransfer
181
+ // 3. Create flow matrix for self-transfer
182
+ // 4. Handle wrap calls for leftover inflationary tokens
183
+ throw new Error('constructReplenish is not yet implemented. Please use constructAdvancedTransfer with same from/to address as a workaround.');
184
+ }
185
+ // ============================================================================
186
+ // Private Helper Methods
187
+ // ============================================================================
188
+ /**
189
+ * Fetches token balances and creates a map for quick lookup
190
+ *
191
+ * @param from Source avatar address
192
+ * @returns Map of token address to balance (in static units)
193
+ */
194
+ async _getTokenBalanceMap(from) {
195
+ const allBalances = await this.rpc.balance.getTokenBalances(from);
196
+ const balanceMap = new Map();
197
+ // @todo remove any
198
+ allBalances.forEach((balance) => {
199
+ balanceMap.set(balance.tokenAddress.toLowerCase(), balance.staticAttoCircles);
200
+ });
201
+ return balanceMap;
202
+ }
203
+ /**
204
+ * Creates unwrap transaction calls for demurraged ERC20 wrapped tokens
205
+ * Unwraps only the exact amount used in the path
206
+ *
207
+ * @param wrappedTokensInPath Map of wrapped token addresses to [amount used in path, type]
208
+ * @returns Array of unwrap transaction calls for demurraged tokens
209
+ */
210
+ _createDemurragedUnwrapCalls(wrappedTokensInPath) {
211
+ const unwrapCalls = [];
212
+ for (const [wrapperAddr, [amountUsedInPath, type]] of Object.entries(wrappedTokensInPath)) {
213
+ // Only process demurraged wrappers
214
+ if (type !== 'CrcV2_ERC20WrapperDeployed_Demurraged') {
215
+ continue;
216
+ }
217
+ // Create unwrap call for the exact amount used in path
218
+ const data = encodeFunctionData({
219
+ abi: [{
220
+ type: 'function',
221
+ name: 'unwrap',
222
+ inputs: [{ name: '_amount', type: 'uint256' }],
223
+ outputs: [],
224
+ stateMutability: 'nonpayable',
225
+ }],
226
+ functionName: 'unwrap',
227
+ args: [amountUsedInPath],
228
+ });
229
+ unwrapCalls.push({
230
+ to: wrapperAddr,
231
+ data,
232
+ value: 0n,
233
+ });
234
+ }
235
+ return unwrapCalls;
236
+ }
237
+ /**
238
+ * Creates unwrap and wrap transaction calls for inflationary ERC20 wrapped tokens
239
+ * Unwraps the entire balance, then wraps back leftover tokens after transfer
240
+ *
241
+ * @param wrappedTokensInPath Map of wrapped token addresses to [amount used in path, type]
242
+ * @param tokenInfoMap Map of token addresses to TokenInfo
243
+ * @param balanceMap Map of token address to balance
244
+ * @returns Object containing unwrap and wrap transaction calls for inflationary tokens
245
+ */
246
+ _createInflationaryUnwrapAndWrapCalls(wrappedTokensInPath, tokenInfoMap, balanceMap) {
247
+ const unwrapCalls = [];
248
+ const wrapCalls = [];
249
+ for (const [wrapperAddr, [amountUsedInPath, type]] of Object.entries(wrappedTokensInPath)) {
250
+ // Only process inflationary wrappers
251
+ if (type !== 'CrcV2_ERC20WrapperDeployed_Inflationary') {
252
+ continue;
253
+ }
254
+ const tokenInfo = tokenInfoMap.get(wrapperAddr.toLowerCase());
255
+ const currentBalance = balanceMap.get(wrapperAddr.toLowerCase()) || 0n;
256
+ if (currentBalance === 0n) {
257
+ continue;
258
+ }
259
+ // Create unwrap call for the entire balance (in static units)
260
+ const unwrapData = encodeFunctionData({
261
+ abi: [{
262
+ type: 'function',
263
+ name: 'unwrap',
264
+ inputs: [{ name: '_amount', type: 'uint256' }],
265
+ outputs: [],
266
+ stateMutability: 'nonpayable',
267
+ }],
268
+ functionName: 'unwrap',
269
+ args: [currentBalance],
270
+ });
271
+ unwrapCalls.push({
272
+ to: wrapperAddr,
273
+ data: unwrapData,
274
+ value: 0n,
275
+ });
276
+ // Calculate leftover amount: balance before unwrap (converted to demurraged) - amount used in path
277
+ const tokenOwner = tokenInfo?.tokenOwner;
278
+ const leftoverAmount = CirclesConverter.attoStaticCirclesToAttoCircles(currentBalance) - amountUsedInPath;
279
+ // Only create wrap call if there's leftover amount
280
+ if (leftoverAmount > 0n) {
281
+ // Create wrap call using hubV2 contract
282
+ const wrapTx = this.core.hubV2.wrap(tokenOwner, leftoverAmount, CirclesType.Inflation // 1 = Inflationary
283
+ );
284
+ wrapCalls.push({
285
+ to: wrapTx.to,
286
+ data: wrapTx.data,
287
+ value: wrapTx.value ?? 0n,
288
+ });
289
+ }
290
+ }
291
+ return { unwrapCalls, wrapCalls };
292
+ }
293
+ /**
294
+ * Helper method to truncate amount to 6 decimals
295
+ */
296
+ _truncateToSixDecimals(amount) {
297
+ const oneMillion = BigInt(1_000_000);
298
+ const oneEth = BigInt(10) ** BigInt(18);
299
+ return (amount / (oneEth / oneMillion)) * (oneEth / oneMillion);
300
+ }
301
+ /**
302
+ * Get default token exclusion list for transfers to group mint handlers
303
+ * If the recipient is a group mint handler, exclude the group token and its wrappers
304
+ *
305
+ * @param to Recipient address
306
+ * @param excludeFromTokens Existing token exclusion list
307
+ * @returns Complete token exclusion list, or undefined if empty
308
+ */
309
+ async _getDefaultTokenExcludeList(to, excludeFromTokens) {
310
+ // Check if recipient is a group mint handler
311
+ const groups = await this.rpc.group.findGroups(1, {
312
+ mintHandlerEquals: to,
313
+ });
314
+ const completeExcludeFromTokenList = new Set();
315
+ // If recipient is a group mint handler, exclude the group's tokens
316
+ if (groups.length > 0) {
317
+ const groupInfo = groups[0];
318
+ completeExcludeFromTokenList.add(groupInfo.group.toLowerCase());
319
+ if (groupInfo.erc20WrapperDemurraged) {
320
+ completeExcludeFromTokenList.add(groupInfo.erc20WrapperDemurraged.toLowerCase());
321
+ }
322
+ if (groupInfo.erc20WrapperStatic) {
323
+ completeExcludeFromTokenList.add(groupInfo.erc20WrapperStatic.toLowerCase());
324
+ }
325
+ }
326
+ // Add any user-provided exclusions
327
+ excludeFromTokens?.forEach((token) => completeExcludeFromTokenList.add(token.toLowerCase()));
328
+ if (completeExcludeFromTokenList.size === 0) {
329
+ return undefined;
330
+ }
331
+ return Array.from(completeExcludeFromTokenList);
332
+ }
333
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=TransferBuilder.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TransferBuilder.test.d.ts","sourceRoot":"","sources":["../src/TransferBuilder.test.ts"],"names":[],"mappings":""}