Skip to content

Commit 4bb5ac5

Browse files
apply review fixes, simplify delegate and frozen reasoning
1 parent ddb98b9 commit 4bb5ac5

10 files changed

Lines changed: 452 additions & 160 deletions

js/compressed-token/src/v3/actions/load-ata.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,7 @@ import {
1111
TransactionSignature,
1212
ConfirmOptions,
1313
} from '@solana/web3.js';
14-
import {
15-
createLoadAtaInstructions,
16-
} from '../instructions/load-ata';
14+
import { createLoadAtaInstructions } from '../instructions/load-ata';
1715
import { InterfaceOptions } from './transfer-interface';
1816

1917
export {
@@ -60,12 +58,7 @@ export async function loadAta(
6058

6159
const txPromises = batches.map(async ixs => {
6260
const { blockhash } = await rpc.getLatestBlockhash();
63-
const tx = buildAndSignTx(
64-
ixs,
65-
payer!,
66-
blockhash,
67-
additionalSigners,
68-
);
61+
const tx = buildAndSignTx(ixs, payer!, blockhash, additionalSigners);
6962
return sendAndConfirmTx(rpc, tx, confirmOptions);
7063
});
7164

js/compressed-token/src/v3/actions/transfer-interface.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import { type SplInterfaceInfo } from '../../utils/get-token-pool-infos';
1919

2020
export interface InterfaceOptions {
2121
splInterfaceInfos?: SplInterfaceInfo[];
22+
/**
23+
* ATA owner (authority owner) used to derive the ATA when the signer is a
24+
* delegate. For owner-signed flows, omit this field.
25+
*/
2226
owner?: PublicKey;
2327
}
2428

@@ -88,7 +92,6 @@ export interface TransferOptions extends InterfaceOptions {
8892
wrap?: boolean;
8993
programId?: PublicKey;
9094
ensureRecipientAta?: boolean;
91-
owner?: PublicKey;
9295
}
9396

9497
export function sliceLast<T>(items: T[]): { rest: T[]; last: T } {

js/compressed-token/src/v3/get-account-interface.ts

Lines changed: 77 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -901,11 +901,49 @@ export function buildAccountInterfaceFromSources(
901901
const hasDelegate = sources.some(src => src.parsed.delegate !== null);
902902
const anyFrozen = sources.some(src => src.parsed.isFrozen);
903903
const needsConsolidation = sources.length > 1;
904+
const delegateTotals = new Map<
905+
string,
906+
{ delegate: PublicKey; total: bigint; firstIndex: number }
907+
>();
908+
for (let i = 0; i < sources.length; i++) {
909+
const src = sources[i];
910+
const delegate = src.parsed.delegate;
911+
if (!delegate) continue;
912+
const key = delegate.toBase58();
913+
const delegated = src.parsed.delegatedAmount ?? src.amount;
914+
const spendable = src.amount < delegated ? src.amount : delegated;
915+
const existing = delegateTotals.get(key);
916+
if (existing) {
917+
existing.total += spendable;
918+
} else {
919+
delegateTotals.set(key, {
920+
delegate,
921+
total: spendable,
922+
firstIndex: i,
923+
});
924+
}
925+
}
926+
let canonicalDelegate: PublicKey | null = null;
927+
let canonicalDelegatedAmount = BigInt(0);
928+
let canonicalFirstIndex = Number.MAX_SAFE_INTEGER;
929+
for (const { delegate, total, firstIndex } of delegateTotals.values()) {
930+
if (
931+
total > canonicalDelegatedAmount ||
932+
(total === canonicalDelegatedAmount &&
933+
firstIndex < canonicalFirstIndex)
934+
) {
935+
canonicalDelegate = delegate;
936+
canonicalDelegatedAmount = total;
937+
canonicalFirstIndex = firstIndex;
938+
}
939+
}
904940

905941
const unifiedAccount: Account = {
906942
...primarySource.parsed,
907943
address: canonicalAddress,
908944
amount: totalAmount,
945+
delegate: canonicalDelegate,
946+
delegatedAmount: canonicalDelegatedAmount,
909947
...(anyFrozen ? { state: AccountState.Frozen, isFrozen: true } : {}),
910948
};
911949

@@ -924,38 +962,30 @@ export function buildAccountInterfaceFromSources(
924962
/**
925963
* Spendable amount for a given authority (owner or delegate).
926964
* - If authority equals the ATA owner: full parsed.amount.
927-
* - If authority is a delegate: sum over sources where delegate === authority
928-
* of min(source.amount, source.delegatedAmount).
929-
*
930-
* For compress-and-close accounts (CompressedOnly TLV), decompress carries
931-
* delegate state to the hot ATA. For approve-style accounts (no TLV), the
932-
* delegate is set in token data but NOT applied to the hot ATA on decompress.
933-
* The transfer-interface validates this and errors for approve-style cold
934-
* sources that require loading.
965+
* - If authority is the canonical delegate: parsed.delegatedAmount (bounded by parsed.amount).
966+
* - Otherwise: 0.
935967
* @internal
936968
*/
937969
export function spendableAmountForAuthority(
938970
iface: AccountInterface,
939971
authority: PublicKey,
940972
): bigint {
941973
const owner = iface._owner;
942-
const sources = iface._sources ?? [];
943974
if (owner && authority.equals(owner)) {
944975
return iface.parsed.amount;
945976
}
946-
let sum = BigInt(0);
947-
for (const src of sources) {
948-
if (src.parsed.delegate && authority.equals(src.parsed.delegate)) {
949-
const amt = src.amount;
950-
const delegated = src.parsed.delegatedAmount ?? amt;
951-
sum += amt < delegated ? amt : delegated;
952-
}
977+
const delegate = iface.parsed.delegate;
978+
if (delegate && authority.equals(delegate)) {
979+
const delegated = iface.parsed.delegatedAmount ?? BigInt(0);
980+
return delegated < iface.parsed.amount
981+
? delegated
982+
: iface.parsed.amount;
953983
}
954-
return sum;
984+
return BigInt(0);
955985
}
956986

957987
/**
958-
* Whether the given authority can sign for this ATA (is owner or delegate of at least one source).
988+
* Whether the given authority can sign for this ATA (owner or canonical delegate).
959989
* @internal
960990
*/
961991
export function isAuthorityForInterface(
@@ -964,57 +994,57 @@ export function isAuthorityForInterface(
964994
): boolean {
965995
const owner = iface._owner;
966996
if (owner && authority.equals(owner)) return true;
967-
const sources = iface._sources ?? [];
968-
return sources.some(
969-
src =>
970-
src.parsed.delegate !== null &&
971-
authority.equals(src.parsed.delegate),
972-
);
997+
const delegate = iface.parsed.delegate;
998+
return delegate !== null && authority.equals(delegate);
973999
}
9741000

9751001
/**
9761002
* @internal
977-
* Filter an AccountInterface to only sources the given authority can use (owner or delegate).
978-
* Preserves _owner, _mint, _isAta. Use for load/transfer when authority is delegate.
1003+
* Canonical authority projection for owner/delegate checks.
9791004
*/
9801005
export function filterInterfaceForAuthority(
9811006
iface: AccountInterface,
9821007
authority: PublicKey,
9831008
): AccountInterface {
984-
const sources = iface._sources ?? [];
9851009
const owner = iface._owner;
986-
const filtered = sources.filter(
987-
src =>
988-
(owner && authority.equals(owner)) ||
989-
(src.parsed.delegate !== null &&
990-
authority.equals(src.parsed.delegate)),
991-
);
992-
if (filtered.length === 0) {
1010+
if (owner && authority.equals(owner)) {
1011+
return iface;
1012+
}
1013+
const spendable = spendableAmountForAuthority(iface, authority);
1014+
const canonicalDelegate = iface.parsed.delegate;
1015+
if (
1016+
spendable === BigInt(0) ||
1017+
canonicalDelegate === null ||
1018+
!authority.equals(canonicalDelegate)
1019+
) {
9931020
return {
9941021
...iface,
9951022
_sources: [],
1023+
_needsConsolidation: false,
9961024
parsed: { ...iface.parsed, amount: BigInt(0) },
9971025
};
9981026
}
999-
const spendable = spendableAmountForAuthority(iface, authority);
1027+
const sources = iface._sources ?? [];
1028+
const filtered = sources.filter(
1029+
src =>
1030+
src.parsed.delegate !== null &&
1031+
src.parsed.delegate.equals(canonicalDelegate),
1032+
);
10001033
const primary = filtered[0];
1001-
const anyFrozen = filtered.some(s => s.parsed.isFrozen);
10021034
return {
10031035
...iface,
1036+
...(primary
1037+
? {
1038+
accountInfo: primary.accountInfo!,
1039+
isCold: isColdSourceType(primary.type),
1040+
loadContext: primary.loadContext,
1041+
}
1042+
: {}),
10041043
_sources: filtered,
1005-
accountInfo: primary.accountInfo!,
1044+
_needsConsolidation: filtered.length > 1,
10061045
parsed: {
1007-
...primary.parsed,
1008-
address: iface.parsed.address,
1046+
...iface.parsed,
10091047
amount: spendable,
1010-
...(anyFrozen
1011-
? { state: AccountState.Frozen, isFrozen: true }
1012-
: {}),
10131048
},
1014-
isCold: isColdSourceType(primary.type),
1015-
loadContext: primary.loadContext,
1016-
_needsConsolidation: filtered.length > 1,
1017-
_hasDelegate: filtered.some(s => s.parsed.delegate !== null),
1018-
_anyFrozen: anyFrozen,
10191049
};
10201050
}

js/compressed-token/src/v3/instructions/create-decompress-interface-instruction.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ function parseCompressedOnlyFromTlv(
6464
30: 1,
6565
31: 17,
6666
};
67-
const size = SIZES[disc] ?? 0;
67+
const size = SIZES[disc];
6868
if (size === undefined) return null;
6969
offset += size;
7070
}
@@ -118,6 +118,8 @@ function buildInTlv(
118118
delegatedAmount: co.delegatedAmount,
119119
withheldTransferFee: co.withheldTransferFee,
120120
isFrozen,
121+
// This builder emits a single decompress compression per batch.
122+
// Keep index at 0 unless multi-compression output is added here.
121123
compressionIndex: 0,
122124
isAta: co.isAta,
123125
bump,
@@ -416,6 +418,18 @@ export function createDecompressInterfaceInstruction(
416418
registeredProgramPda,
417419
accountCompressionProgram,
418420
} = defaultStaticAccountsStruct();
421+
const signerIndex = (() => {
422+
if (!authority || authority.equals(owner)) {
423+
return ownerIndex;
424+
}
425+
const authorityIndex = packedAccountIndices.get(authority.toBase58());
426+
if (authorityIndex === undefined) {
427+
throw new Error(
428+
`Authority ${authority.toBase58()} is not present in packed accounts`,
429+
);
430+
}
431+
return authorityIndex;
432+
})();
419433

420434
const keys = [
421435
// 0: light_system_program (non-mutable)
@@ -463,12 +477,6 @@ export function createDecompressInterfaceInstruction(
463477
const isPool =
464478
splInterfaceInfo !== undefined &&
465479
pubkey.equals(splInterfaceInfo.splInterfacePda);
466-
const signerIndex =
467-
authority &&
468-
!authority.equals(owner) &&
469-
packedAccountIndices.has(authority.toBase58())
470-
? packedAccountIndices.get(authority.toBase58())!
471-
: ownerIndex;
472480
return {
473481
pubkey,
474482
isSigner: i === signerIndex,

js/compressed-token/src/v3/instructions/load-ata.ts

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,6 @@ export function getCompressedTokenAccountsFromAtaSources(
101101
return sources
102102
.filter(source => source.loadContext !== undefined)
103103
.filter(source => COLD_SOURCE_TYPES.has(source.type))
104-
.filter(source => !source.parsed.isFrozen)
105104
.map(source => {
106105
const fullData = source.accountInfo.data;
107106
const discriminatorBytes = fullData.subarray(
@@ -197,12 +196,6 @@ export async function createLoadAtaInstructions(
197196
);
198197
}
199198
accountInterface = filterInterfaceForAuthority(accountInterface, owner);
200-
if (
201-
(accountInterface._sources?.length ?? 0) === 0 ||
202-
accountInterface.parsed.amount === BigInt(0)
203-
) {
204-
return [];
205-
}
206199
}
207200

208201
const internalBatches = await _buildLoadBatches(
@@ -318,16 +311,10 @@ export async function _buildLoadBatches(
318311
);
319312
}
320313

321-
const splSource = sources.find(s => s.type === 'spl' && !s.parsed.isFrozen);
322-
const t22Source = sources.find(
323-
s => s.type === 'token2022' && !s.parsed.isFrozen,
324-
);
325-
const ctokenHotSource = sources.find(
326-
s => s.type === 'ctoken-hot' && !s.parsed.isFrozen,
327-
);
328-
const coldSources = sources.filter(
329-
s => COLD_SOURCE_TYPES.has(s.type) && !s.parsed.isFrozen,
330-
);
314+
const splSource = sources.find(s => s.type === 'spl');
315+
const t22Source = sources.find(s => s.type === 'token2022');
316+
const ctokenHotSource = sources.find(s => s.type === 'ctoken-hot');
317+
const coldSources = sources.filter(s => COLD_SOURCE_TYPES.has(s.type));
331318

332319
const splBalance = splSource?.amount ?? BigInt(0);
333320
const t22Balance = t22Source?.amount ?? BigInt(0);

js/compressed-token/src/v3/instructions/transfer-interface.ts

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ import {
3535
filterInterfaceForAuthority,
3636
} from '../get-account-interface';
3737
import { assertTransactionSizeWithinLimit } from '../utils/estimate-tx-size';
38-
import { COLD_SOURCE_TYPES } from '../get-account-interface';
3938
import type { TransferOptions } from '../actions/transfer-interface';
4039

4140
const LIGHT_TOKEN_TRANSFER_DISCRIMINATOR = 3;
@@ -208,25 +207,6 @@ export async function createTransferInterfaceInstructions(
208207
sender,
209208
);
210209

211-
if (isDelegate && internalBatches.length > 0) {
212-
const sources = senderInterface._sources ?? [];
213-
const hasApproveStyleCold = sources.some(
214-
s =>
215-
COLD_SOURCE_TYPES.has(s.type) &&
216-
s.parsed.delegate !== null &&
217-
s.parsed.delegate.equals(sender) &&
218-
(!s.parsed.tlvData || s.parsed.tlvData.length === 0),
219-
);
220-
if (hasApproveStyleCold) {
221-
throw new Error(
222-
'Delegate transfer requires loading cold sources that were delegated ' +
223-
'via approve (no CompressedOnly TLV). Decompress will not carry ' +
224-
'the delegate to the hot ATA. Load as owner first, then approve ' +
225-
'the delegate on the hot ATA.',
226-
);
227-
}
228-
}
229-
230210
let transferIx: TransactionInstruction;
231211
if (isSplOrT22 && !wrap) {
232212
const mintInfo = await getMint(rpc, mint, undefined, programId);

js/compressed-token/src/v3/instructions/unwrap.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import {
44
TransactionInstruction,
55
SystemProgram,
66
} from '@solana/web3.js';
7-
import { Rpc, assertBetaEnabled, LIGHT_TOKEN_PROGRAM_ID } from '@lightprotocol/stateless.js';
7+
import {
8+
Rpc,
9+
assertBetaEnabled,
10+
LIGHT_TOKEN_PROGRAM_ID,
11+
} from '@lightprotocol/stateless.js';
812
import { getMint, TokenAccountNotFoundError } from '@solana/spl-token';
913
import type BN from 'bn.js';
1014
import { MAX_TOP_UP } from '../../constants';
@@ -216,6 +220,10 @@ export async function createUnwrapInstructions(
216220
const unwrapAmount =
217221
amount != null ? BigInt(amount.toString()) : totalBalance;
218222

223+
if (unwrapAmount === BigInt(0)) {
224+
throw new Error('Unwrap amount must be greater than zero.');
225+
}
226+
219227
if (unwrapAmount > totalBalance) {
220228
throw new Error(
221229
`Insufficient light-token balance. Requested: ${unwrapAmount}, Available: ${totalBalance}`,
@@ -229,7 +237,7 @@ export async function createUnwrapInstructions(
229237
interfaceOptions,
230238
wrap,
231239
ctokenAta,
232-
amount !== undefined ? unwrapAmount : undefined,
240+
amount != null ? unwrapAmount : undefined,
233241
);
234242

235243
const mintInfo = await getMint(

0 commit comments

Comments
 (0)