Skip to content

Commit 469ad47

Browse files
feat(solana): support token icons and descriptions on wrapped tokens
Token-2022 has no icon or description fields; both are served from the off-chain JSON document that its `uri` field points at. That field was initialized to an empty string, so wrapped tokens could never carry either. Adds `uri` to `PartialTokenMetadata` and passes it through to the metadata initialize CPI. It is deliberately excluded from the mint's PDA hash: that hash is recomputed from onchain metadata to authenticate a mint as a wrapped token, so extending the preimage would move every already-deployed mint to an address the program no longer derives. `wrap_token` keeps its exact wire format, taking a `PartialTokenMetadataV1` argument whose layout is byte-identical to the previous type, and delegates with an empty uri. `wrap_token_v2` accepts the uri. Both derive the same mint. `set_wrapped_token_uri` fills in a missing uri. It is permissionless because a wrapped mint has no owner, and write-once so an existing uri can never be overwritten. This is what lets tokens wrapped before this change, and those wrapped through the deprecated instruction, get icons. Also fixes `create_mock_wrapped_mint`, which left `update_authority` unset and so did not match a real wrapped mint in the one field metadata writes depend on. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent cf64f80 commit 469ad47

26 files changed

Lines changed: 2628 additions & 204 deletions

File tree

‎clients/ts/src/bridge/generated/errors/bridge.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ export const BRIDGE_ERROR__INVALID_SCALER_EXPONENT = 0x319f; // 12703
8484
export const BRIDGE_ERROR__MINT_IS_NOT_FROM_TOKEN2022 = 0x31a0; // 12704
8585
/** MintIsNotWrappedTokenPda: Mint is not a valid wrapped token PDA */
8686
export const BRIDGE_ERROR__MINT_IS_NOT_WRAPPED_TOKEN_PDA = 0x31a1; // 12705
87+
/** UriTooLong: Token metadata uri is too long */
88+
export const BRIDGE_ERROR__URI_TOO_LONG = 0x31a2; // 12706
89+
/** EmptyUri: Token metadata uri is empty */
90+
export const BRIDGE_ERROR__EMPTY_URI = 0x31a3; // 12707
91+
/** UriAlreadySet: Token metadata uri is already set and cannot be changed */
92+
export const BRIDGE_ERROR__URI_ALREADY_SET = 0x31a4; // 12708
8793
/** InvalidThreshold: Threshold must be <= number of signers */
8894
export const BRIDGE_ERROR__INVALID_THRESHOLD = 0x3200; // 12800
8995
/** TooManySigners: Too many signers (max 32) */
@@ -114,6 +120,7 @@ export type BridgeError =
114120
| typeof BRIDGE_ERROR__CREATION_WITH_NON_ZERO_TARGET
115121
| typeof BRIDGE_ERROR__DUPLICATE_SIGNER
116122
| typeof BRIDGE_ERROR__EMPTY_MMR
123+
| typeof BRIDGE_ERROR__EMPTY_URI
117124
| typeof BRIDGE_ERROR__INCORRECT_BLOCK_NUMBER
118125
| typeof BRIDGE_ERROR__INCORRECT_BRIDGE_PROGRAM
119126
| typeof BRIDGE_ERROR__INCORRECT_GAS_FEE_RECEIVER
@@ -150,6 +157,8 @@ export type BridgeError =
150157
| typeof BRIDGE_ERROR__UNAUTHORIZED_CONFIG_UPDATE
151158
| typeof BRIDGE_ERROR__UNAUTHORIZED_INITIALIZATION
152159
| typeof BRIDGE_ERROR__UNUSED_PROOF_ELEMENTS_REMAINING
160+
| typeof BRIDGE_ERROR__URI_ALREADY_SET
161+
| typeof BRIDGE_ERROR__URI_TOO_LONG
153162
| typeof BRIDGE_ERROR__ZERO_ADDRESS;
154163

155164
let bridgeErrorMessages: Record<BridgeError, string> | undefined;
@@ -163,6 +172,7 @@ if (process.env.NODE_ENV !== 'production') {
163172
[BRIDGE_ERROR__CREATION_WITH_NON_ZERO_TARGET]: `Creation with non-zero target`,
164173
[BRIDGE_ERROR__DUPLICATE_SIGNER]: `Duplicate signer found`,
165174
[BRIDGE_ERROR__EMPTY_MMR]: `MMR is empty`,
175+
[BRIDGE_ERROR__EMPTY_URI]: `Token metadata uri is empty`,
166176
[BRIDGE_ERROR__INCORRECT_BLOCK_NUMBER]: `Incorrect block number`,
167177
[BRIDGE_ERROR__INCORRECT_BRIDGE_PROGRAM]: `Incorrect bridge program`,
168178
[BRIDGE_ERROR__INCORRECT_GAS_FEE_RECEIVER]: `Incorrect gas fee receiver`,
@@ -199,6 +209,8 @@ if (process.env.NODE_ENV !== 'production') {
199209
[BRIDGE_ERROR__UNAUTHORIZED_CONFIG_UPDATE]: `Unauthorized to update configuration`,
200210
[BRIDGE_ERROR__UNAUTHORIZED_INITIALIZATION]: `Only the upgrade authority can initialize the bridge`,
201211
[BRIDGE_ERROR__UNUSED_PROOF_ELEMENTS_REMAINING]: `Unused proof elements remaining`,
212+
[BRIDGE_ERROR__URI_ALREADY_SET]: `Token metadata uri is already set and cannot be changed`,
213+
[BRIDGE_ERROR__URI_TOO_LONG]: `Token metadata uri is too long`,
202214
[BRIDGE_ERROR__ZERO_ADDRESS]: `Zero address`,
203215
};
204216
}

‎clients/ts/src/bridge/generated/instructions/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,5 +39,7 @@ export * from './setOracleSigners';
3939
export * from './setPartnerOracleConfig';
4040
export * from './setPauseStatus';
4141
export * from './setWindowDuration';
42+
export * from './setWrappedTokenUri';
4243
export * from './transferGuardian';
4344
export * from './wrapToken';
45+
export * from './wrapTokenV2';
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/**
2+
* This code was AUTOGENERATED using the Codama library.
3+
* Please DO NOT EDIT THIS FILE, instead use visitors
4+
* to add features, then rerun Codama to update it.
5+
*
6+
* @see https://github.com/codama-idl/codama
7+
*/
8+
9+
import {
10+
addDecoderSizePrefix,
11+
addEncoderSizePrefix,
12+
combineCodec,
13+
fixDecoderSize,
14+
fixEncoderSize,
15+
getBytesDecoder,
16+
getBytesEncoder,
17+
getStructDecoder,
18+
getStructEncoder,
19+
getU32Decoder,
20+
getU32Encoder,
21+
getUtf8Decoder,
22+
getUtf8Encoder,
23+
transformEncoder,
24+
type AccountMeta,
25+
type AccountSignerMeta,
26+
type Address,
27+
type Codec,
28+
type Decoder,
29+
type Encoder,
30+
type Instruction,
31+
type InstructionWithAccounts,
32+
type InstructionWithData,
33+
type ReadonlyAccount,
34+
type ReadonlyUint8Array,
35+
type TransactionSigner,
36+
type WritableAccount,
37+
type WritableSignerAccount,
38+
} from '@solana/kit';
39+
import { BRIDGE_PROGRAM_ADDRESS } from '../programs';
40+
import { getAccountMetaFactory, type ResolvedAccount } from '../shared';
41+
42+
export const SET_WRAPPED_TOKEN_URI_DISCRIMINATOR = new Uint8Array([
43+
248, 70, 157, 26, 216, 32, 48, 11,
44+
]);
45+
46+
export function getSetWrappedTokenUriDiscriminatorBytes() {
47+
return fixEncoderSize(getBytesEncoder(), 8).encode(
48+
SET_WRAPPED_TOKEN_URI_DISCRIMINATOR
49+
);
50+
}
51+
52+
export type SetWrappedTokenUriInstruction<
53+
TProgram extends string = typeof BRIDGE_PROGRAM_ADDRESS,
54+
TAccountPayer extends string | AccountMeta<string> = string,
55+
TAccountMint extends string | AccountMeta<string> = string,
56+
TAccountBridge extends string | AccountMeta<string> = string,
57+
TAccountTokenProgram extends
58+
| string
59+
| AccountMeta<string> = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
60+
TAccountSystemProgram extends
61+
| string
62+
| AccountMeta<string> = '11111111111111111111111111111111',
63+
TRemainingAccounts extends readonly AccountMeta<string>[] = [],
64+
> = Instruction<TProgram> &
65+
InstructionWithData<ReadonlyUint8Array> &
66+
InstructionWithAccounts<
67+
[
68+
TAccountPayer extends string
69+
? WritableSignerAccount<TAccountPayer> &
70+
AccountSignerMeta<TAccountPayer>
71+
: TAccountPayer,
72+
TAccountMint extends string
73+
? WritableAccount<TAccountMint>
74+
: TAccountMint,
75+
TAccountBridge extends string
76+
? ReadonlyAccount<TAccountBridge>
77+
: TAccountBridge,
78+
TAccountTokenProgram extends string
79+
? ReadonlyAccount<TAccountTokenProgram>
80+
: TAccountTokenProgram,
81+
TAccountSystemProgram extends string
82+
? ReadonlyAccount<TAccountSystemProgram>
83+
: TAccountSystemProgram,
84+
...TRemainingAccounts,
85+
]
86+
>;
87+
88+
export type SetWrappedTokenUriInstructionData = {
89+
discriminator: ReadonlyUint8Array;
90+
uri: string;
91+
};
92+
93+
export type SetWrappedTokenUriInstructionDataArgs = { uri: string };
94+
95+
export function getSetWrappedTokenUriInstructionDataEncoder(): Encoder<SetWrappedTokenUriInstructionDataArgs> {
96+
return transformEncoder(
97+
getStructEncoder([
98+
['discriminator', fixEncoderSize(getBytesEncoder(), 8)],
99+
['uri', addEncoderSizePrefix(getUtf8Encoder(), getU32Encoder())],
100+
]),
101+
(value) => ({
102+
...value,
103+
discriminator: SET_WRAPPED_TOKEN_URI_DISCRIMINATOR,
104+
})
105+
);
106+
}
107+
108+
export function getSetWrappedTokenUriInstructionDataDecoder(): Decoder<SetWrappedTokenUriInstructionData> {
109+
return getStructDecoder([
110+
['discriminator', fixDecoderSize(getBytesDecoder(), 8)],
111+
['uri', addDecoderSizePrefix(getUtf8Decoder(), getU32Decoder())],
112+
]);
113+
}
114+
115+
export function getSetWrappedTokenUriInstructionDataCodec(): Codec<
116+
SetWrappedTokenUriInstructionDataArgs,
117+
SetWrappedTokenUriInstructionData
118+
> {
119+
return combineCodec(
120+
getSetWrappedTokenUriInstructionDataEncoder(),
121+
getSetWrappedTokenUriInstructionDataDecoder()
122+
);
123+
}
124+
125+
export type SetWrappedTokenUriInput<
126+
TAccountPayer extends string = string,
127+
TAccountMint extends string = string,
128+
TAccountBridge extends string = string,
129+
TAccountTokenProgram extends string = string,
130+
TAccountSystemProgram extends string = string,
131+
> = {
132+
/** The account that pays for the mint account's rent increase. */
133+
payer: TransactionSigner<TAccountPayer>;
134+
/**
135+
* The wrapped token mint whose uri is being filled in. Validated to be a wrapped token PDA of
136+
* this program by recomputing its metadata hash, which is what makes the mint's own signature
137+
* available for the metadata update below.
138+
*/
139+
mint: Address<TAccountMint>;
140+
/** The main bridge state account, read to honor the pause switch. */
141+
bridge: Address<TAccountBridge>;
142+
/** SPL Token-2022 program that owns the mint's metadata extension. */
143+
tokenProgram?: Address<TAccountTokenProgram>;
144+
/** System program used to top up the mint's rent before its metadata grows. */
145+
systemProgram?: Address<TAccountSystemProgram>;
146+
uri: SetWrappedTokenUriInstructionDataArgs['uri'];
147+
};
148+
149+
export function getSetWrappedTokenUriInstruction<
150+
TAccountPayer extends string,
151+
TAccountMint extends string,
152+
TAccountBridge extends string,
153+
TAccountTokenProgram extends string,
154+
TAccountSystemProgram extends string,
155+
TProgramAddress extends Address = typeof BRIDGE_PROGRAM_ADDRESS,
156+
>(
157+
input: SetWrappedTokenUriInput<
158+
TAccountPayer,
159+
TAccountMint,
160+
TAccountBridge,
161+
TAccountTokenProgram,
162+
TAccountSystemProgram
163+
>,
164+
config?: { programAddress?: TProgramAddress }
165+
): SetWrappedTokenUriInstruction<
166+
TProgramAddress,
167+
TAccountPayer,
168+
TAccountMint,
169+
TAccountBridge,
170+
TAccountTokenProgram,
171+
TAccountSystemProgram
172+
> {
173+
// Program address.
174+
const programAddress = config?.programAddress ?? BRIDGE_PROGRAM_ADDRESS;
175+
176+
// Original accounts.
177+
const originalAccounts = {
178+
payer: { value: input.payer ?? null, isWritable: true },
179+
mint: { value: input.mint ?? null, isWritable: true },
180+
bridge: { value: input.bridge ?? null, isWritable: false },
181+
tokenProgram: { value: input.tokenProgram ?? null, isWritable: false },
182+
systemProgram: { value: input.systemProgram ?? null, isWritable: false },
183+
};
184+
const accounts = originalAccounts as Record<
185+
keyof typeof originalAccounts,
186+
ResolvedAccount
187+
>;
188+
189+
// Original args.
190+
const args = { ...input };
191+
192+
// Resolve default values.
193+
if (!accounts.tokenProgram.value) {
194+
accounts.tokenProgram.value =
195+
'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA' as Address<'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'>;
196+
}
197+
if (!accounts.systemProgram.value) {
198+
accounts.systemProgram.value =
199+
'11111111111111111111111111111111' as Address<'11111111111111111111111111111111'>;
200+
}
201+
202+
const getAccountMeta = getAccountMetaFactory(programAddress, 'programId');
203+
return Object.freeze({
204+
accounts: [
205+
getAccountMeta(accounts.payer),
206+
getAccountMeta(accounts.mint),
207+
getAccountMeta(accounts.bridge),
208+
getAccountMeta(accounts.tokenProgram),
209+
getAccountMeta(accounts.systemProgram),
210+
],
211+
data: getSetWrappedTokenUriInstructionDataEncoder().encode(
212+
args as SetWrappedTokenUriInstructionDataArgs
213+
),
214+
programAddress,
215+
} as SetWrappedTokenUriInstruction<
216+
TProgramAddress,
217+
TAccountPayer,
218+
TAccountMint,
219+
TAccountBridge,
220+
TAccountTokenProgram,
221+
TAccountSystemProgram
222+
>);
223+
}
224+
225+
export type ParsedSetWrappedTokenUriInstruction<
226+
TProgram extends string = typeof BRIDGE_PROGRAM_ADDRESS,
227+
TAccountMetas extends readonly AccountMeta[] = readonly AccountMeta[],
228+
> = {
229+
programAddress: Address<TProgram>;
230+
accounts: {
231+
/** The account that pays for the mint account's rent increase. */
232+
payer: TAccountMetas[0];
233+
/**
234+
* The wrapped token mint whose uri is being filled in. Validated to be a wrapped token PDA of
235+
* this program by recomputing its metadata hash, which is what makes the mint's own signature
236+
* available for the metadata update below.
237+
*/
238+
mint: TAccountMetas[1];
239+
/** The main bridge state account, read to honor the pause switch. */
240+
bridge: TAccountMetas[2];
241+
/** SPL Token-2022 program that owns the mint's metadata extension. */
242+
tokenProgram: TAccountMetas[3];
243+
/** System program used to top up the mint's rent before its metadata grows. */
244+
systemProgram: TAccountMetas[4];
245+
};
246+
data: SetWrappedTokenUriInstructionData;
247+
};
248+
249+
export function parseSetWrappedTokenUriInstruction<
250+
TProgram extends string,
251+
TAccountMetas extends readonly AccountMeta[],
252+
>(
253+
instruction: Instruction<TProgram> &
254+
InstructionWithAccounts<TAccountMetas> &
255+
InstructionWithData<ReadonlyUint8Array>
256+
): ParsedSetWrappedTokenUriInstruction<TProgram, TAccountMetas> {
257+
if (instruction.accounts.length < 5) {
258+
// TODO: Coded error.
259+
throw new Error('Not enough accounts');
260+
}
261+
let accountIndex = 0;
262+
const getNextAccount = () => {
263+
const accountMeta = (instruction.accounts as TAccountMetas)[accountIndex]!;
264+
accountIndex += 1;
265+
return accountMeta;
266+
};
267+
return {
268+
programAddress: instruction.programAddress,
269+
accounts: {
270+
payer: getNextAccount(),
271+
mint: getNextAccount(),
272+
bridge: getNextAccount(),
273+
tokenProgram: getNextAccount(),
274+
systemProgram: getNextAccount(),
275+
},
276+
data: getSetWrappedTokenUriInstructionDataDecoder().decode(
277+
instruction.data
278+
),
279+
};
280+
}

‎clients/ts/src/bridge/generated/instructions/wrapToken.ts‎

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -101,19 +101,9 @@ export type WrapTokenInstructionData = {
101101
name: string;
102102
/** The symbol/ticker of the token (e.g., "WBTC") */
103103
symbol: string;
104-
/**
105-
* The 20-byte address of the corresponding token contract on Base (EVM address bytes).
106-
* This allows the bridge to identify which Base token this Solana token represents.
107-
*/
104+
/** The 20-byte address of the corresponding token contract on Base (EVM address bytes). */
108105
remoteToken: ReadonlyUint8Array;
109-
/**
110-
* The scaling exponent used to convert between token amounts on different chains.
111-
* This handles cases where tokens have differing decimal precision on Base vs Solana.
112-
* For example, when Base token has 18 decimals and the Solana wrapped mint has 9,
113-
* this value conveys the decimal relationship so bridging logic can scale amounts.
114-
* The exact conversion is performed by the EVM-side contract; Solana propagates this
115-
* value but does not apply arithmetic with it.
116-
*/
106+
/** The scaling exponent used to convert between token amounts on different chains. */
117107
scalerExponent: number;
118108
};
119109

@@ -124,19 +114,9 @@ export type WrapTokenInstructionDataArgs = {
124114
name: string;
125115
/** The symbol/ticker of the token (e.g., "WBTC") */
126116
symbol: string;
127-
/**
128-
* The 20-byte address of the corresponding token contract on Base (EVM address bytes).
129-
* This allows the bridge to identify which Base token this Solana token represents.
130-
*/
117+
/** The 20-byte address of the corresponding token contract on Base (EVM address bytes). */
131118
remoteToken: ReadonlyUint8Array;
132-
/**
133-
* The scaling exponent used to convert between token amounts on different chains.
134-
* This handles cases where tokens have differing decimal precision on Base vs Solana.
135-
* For example, when Base token has 18 decimals and the Solana wrapped mint has 9,
136-
* this value conveys the decimal relationship so bridging logic can scale amounts.
137-
* The exact conversion is performed by the EVM-side contract; Solana propagates this
138-
* value but does not apply arithmetic with it.
139-
*/
119+
/** The scaling exponent used to convert between token amounts on different chains. */
140120
scalerExponent: number;
141121
};
142122

0 commit comments

Comments
 (0)