Skip to content

Commit f489aac

Browse files
authored
fix(token,token-2022): per-instruction ProgramID override (closes #254) (#439)
* fix(token,token-2022): per-instruction ProgramID override Allow mixing SPL Token and SPL Token-2022 instructions in the same process without mutating the package-level ProgramID, which is not goroutine-safe. Adds an optional per-instance ProgramID override via SetProgramID on *Instruction in both programs/token and programs/token-2022. ProgramID() prefers the override and falls back to the package-level value, so existing callers see no behavior change. Tested under -race with 32 concurrent goroutines mixing default and override paths. Closes #254. --------- Signed-off-by: ozpool <jittendersingh389@gmail.com>
1 parent 9fcbf0c commit f489aac

4 files changed

Lines changed: 285 additions & 0 deletions

File tree

programs/token-2022/instructions.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,22 @@ func InstructionIDToName(id uint8) string {
303303

304304
type Instruction struct {
305305
ag_binary.BaseVariant
306+
307+
// programIDOverride, when non-nil, replaces the package-level ProgramID
308+
// for this instruction only. Lets callers issue SPL Token and SPL
309+
// Token-2022 instructions in the same process without flipping the
310+
// package-level ProgramID via SetProgramID — that mutation is not
311+
// safe across goroutines.
312+
programIDOverride *ag_solanago.PublicKey
313+
}
314+
315+
// SetProgramID overrides the program ID used by this instruction only,
316+
// leaving the package-level ProgramID untouched. Use this to keep SPL
317+
// Token-2022 instructions independent from any other concurrent caller
318+
// that may have flipped the package ProgramID.
319+
func (inst *Instruction) SetProgramID(id ag_solanago.PublicKey) *Instruction {
320+
inst.programIDOverride = &id
321+
return inst
306322
}
307323

308324
func (inst *Instruction) EncodeToTree(parent ag_treeout.Branches) {
@@ -461,6 +477,9 @@ var InstructionImplDef = ag_binary.NewVariantDefinition(
461477
)
462478

463479
func (inst *Instruction) ProgramID() ag_solanago.PublicKey {
480+
if inst.programIDOverride != nil {
481+
return *inst.programIDOverride
482+
}
464483
return ProgramID
465484
}
466485

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright 2026 github.com/gagliardetto
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package token2022
16+
17+
import (
18+
"sync"
19+
"testing"
20+
21+
"github.com/gagliardetto/solana-go"
22+
"github.com/stretchr/testify/require"
23+
)
24+
25+
// TestInstruction_SetProgramID_DefaultMatchesPackage ensures an
26+
// instruction built without an override falls back to the package
27+
// ProgramID, so existing callers see no behavior change.
28+
func TestInstruction_SetProgramID_DefaultMatchesPackage(t *testing.T) {
29+
inst := NewTransferInstructionBuilder().
30+
SetAmount(1).
31+
SetSourceAccount(solana.MustPublicKeyFromBase58("11111111111111111111111111111112")).
32+
SetDestinationAccount(solana.MustPublicKeyFromBase58("11111111111111111111111111111113")).
33+
SetOwnerAccount(solana.MustPublicKeyFromBase58("11111111111111111111111111111114")).
34+
Build()
35+
36+
require.Equal(t, ProgramID, inst.ProgramID())
37+
}
38+
39+
// TestInstruction_SetProgramID_OverrideIsPerInstance verifies that
40+
// SetProgramID only affects the receiver and does not leak into either
41+
// the package ProgramID or a sibling instruction.
42+
func TestInstruction_SetProgramID_OverrideIsPerInstance(t *testing.T) {
43+
originalProgramID := ProgramID
44+
t.Cleanup(func() { ProgramID = originalProgramID })
45+
46+
customID := solana.MustPublicKeyFromBase58("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
47+
src := solana.MustPublicKeyFromBase58("11111111111111111111111111111112")
48+
dst := solana.MustPublicKeyFromBase58("11111111111111111111111111111113")
49+
owner := solana.MustPublicKeyFromBase58("11111111111111111111111111111114")
50+
51+
defaultInst := NewTransferInstructionBuilder().
52+
SetAmount(1).
53+
SetSourceAccount(src).
54+
SetDestinationAccount(dst).
55+
SetOwnerAccount(owner).
56+
Build()
57+
58+
overriddenInst := NewTransferInstructionBuilder().
59+
SetAmount(2).
60+
SetSourceAccount(src).
61+
SetDestinationAccount(dst).
62+
SetOwnerAccount(owner).
63+
Build().
64+
SetProgramID(customID)
65+
66+
require.Equal(t, ProgramID, defaultInst.ProgramID(),
67+
"default instruction must read the package ProgramID")
68+
require.Equal(t, customID, overriddenInst.ProgramID(),
69+
"overridden instruction must read its own ProgramID")
70+
require.Equal(t, originalProgramID, ProgramID,
71+
"SetProgramID on an instruction must not mutate the package ProgramID")
72+
}
73+
74+
// TestInstruction_SetProgramID_ConcurrentBuildersDoNotRace covers the
75+
// motivating case from issue #254: building Token-2022 instructions in
76+
// parallel with a different-program override must not require a
77+
// process-wide mutex on the package ProgramID. Run under `-race`.
78+
func TestInstruction_SetProgramID_ConcurrentBuildersDoNotRace(t *testing.T) {
79+
originalProgramID := ProgramID
80+
t.Cleanup(func() { ProgramID = originalProgramID })
81+
82+
customID := solana.MustPublicKeyFromBase58("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
83+
src := solana.MustPublicKeyFromBase58("11111111111111111111111111111112")
84+
dst := solana.MustPublicKeyFromBase58("11111111111111111111111111111113")
85+
owner := solana.MustPublicKeyFromBase58("11111111111111111111111111111114")
86+
87+
const goroutines = 32
88+
var wg sync.WaitGroup
89+
wg.Add(goroutines)
90+
for i := 0; i < goroutines; i++ {
91+
i := i
92+
go func() {
93+
defer wg.Done()
94+
95+
inst := NewTransferInstructionBuilder().
96+
SetAmount(uint64(i + 1)).
97+
SetSourceAccount(src).
98+
SetDestinationAccount(dst).
99+
SetOwnerAccount(owner).
100+
Build()
101+
102+
if i%2 == 0 {
103+
inst.SetProgramID(customID)
104+
if inst.ProgramID() != customID {
105+
t.Errorf("goroutine %d: expected customID, got %s", i, inst.ProgramID())
106+
}
107+
} else {
108+
if inst.ProgramID() != ProgramID {
109+
t.Errorf("goroutine %d: expected package ProgramID, got %s", i, inst.ProgramID())
110+
}
111+
}
112+
}()
113+
}
114+
wg.Wait()
115+
116+
require.Equal(t, originalProgramID, ProgramID,
117+
"package ProgramID must be unchanged after concurrent builders")
118+
}

programs/token/instructions.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,32 @@ func InstructionIDToName(id uint8) string {
227227

228228
type Instruction struct {
229229
ag_binary.BaseVariant
230+
231+
// programIDOverride, when non-nil, replaces the package-level ProgramID
232+
// for this instruction only. Lets callers issue SPL Token and SPL
233+
// Token-2022 instructions in the same process without flipping the
234+
// package-level ProgramID via SetProgramID — that mutation is not
235+
// safe across goroutines.
236+
programIDOverride *ag_solanago.PublicKey
237+
}
238+
239+
// SetProgramID overrides the program ID used by this instruction only,
240+
// leaving the package-level ProgramID untouched. Use this to keep SPL
241+
// Token and SPL Token-2022 (or any custom-deployed Token-compatible
242+
// program) instructions independent across concurrent goroutines.
243+
//
244+
// The receiver is returned so the call can be chained on a Build():
245+
//
246+
// inst := token.NewTransferInstructionBuilder().
247+
// SetAmount(1).
248+
// SetSourceAccount(src).
249+
// SetDestinationAccount(dst).
250+
// SetOwnerAccount(owner).
251+
// Build().
252+
// SetProgramID(solana.Token2022ProgramID)
253+
func (inst *Instruction) SetProgramID(id ag_solanago.PublicKey) *Instruction {
254+
inst.programIDOverride = &id
255+
return inst
230256
}
231257

232258
func (inst *Instruction) EncodeToTree(parent ag_treeout.Branches) {
@@ -307,6 +333,9 @@ var InstructionImplDef = ag_binary.NewVariantDefinition(
307333
)
308334

309335
func (inst *Instruction) ProgramID() ag_solanago.PublicKey {
336+
if inst.programIDOverride != nil {
337+
return *inst.programIDOverride
338+
}
310339
return ProgramID
311340
}
312341

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
// Copyright 2026 github.com/gagliardetto
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package token
16+
17+
import (
18+
"sync"
19+
"testing"
20+
21+
"github.com/gagliardetto/solana-go"
22+
"github.com/stretchr/testify/require"
23+
)
24+
25+
// TestInstruction_SetProgramID_DefaultMatchesPackage ensures that an
26+
// instruction built without an override falls back to the package-level
27+
// ProgramID, preserving prior behavior for the default code path.
28+
func TestInstruction_SetProgramID_DefaultMatchesPackage(t *testing.T) {
29+
inst := NewTransferInstructionBuilder().
30+
SetAmount(1).
31+
SetSourceAccount(solana.MustPublicKeyFromBase58("11111111111111111111111111111112")).
32+
SetDestinationAccount(solana.MustPublicKeyFromBase58("11111111111111111111111111111113")).
33+
SetOwnerAccount(solana.MustPublicKeyFromBase58("11111111111111111111111111111114")).
34+
Build()
35+
36+
require.Equal(t, ProgramID, inst.ProgramID())
37+
}
38+
39+
// TestInstruction_SetProgramID_OverrideIsPerInstance verifies that
40+
// SetProgramID only affects the receiver and does not leak into either
41+
// the package-level ProgramID or a sibling instruction built off the
42+
// same builder package.
43+
func TestInstruction_SetProgramID_OverrideIsPerInstance(t *testing.T) {
44+
originalProgramID := ProgramID
45+
t.Cleanup(func() { ProgramID = originalProgramID })
46+
47+
src := solana.MustPublicKeyFromBase58("11111111111111111111111111111112")
48+
dst := solana.MustPublicKeyFromBase58("11111111111111111111111111111113")
49+
owner := solana.MustPublicKeyFromBase58("11111111111111111111111111111114")
50+
51+
defaultInst := NewTransferInstructionBuilder().
52+
SetAmount(1).
53+
SetSourceAccount(src).
54+
SetDestinationAccount(dst).
55+
SetOwnerAccount(owner).
56+
Build()
57+
58+
overriddenInst := NewTransferInstructionBuilder().
59+
SetAmount(2).
60+
SetSourceAccount(src).
61+
SetDestinationAccount(dst).
62+
SetOwnerAccount(owner).
63+
Build().
64+
SetProgramID(solana.Token2022ProgramID)
65+
66+
require.Equal(t, ProgramID, defaultInst.ProgramID(),
67+
"default instruction must read the package ProgramID")
68+
require.Equal(t, solana.Token2022ProgramID, overriddenInst.ProgramID(),
69+
"overridden instruction must read its own ProgramID")
70+
require.Equal(t, originalProgramID, ProgramID,
71+
"SetProgramID on an instruction must not mutate the package ProgramID")
72+
}
73+
74+
// TestInstruction_SetProgramID_ConcurrentBuildersDoNotRace covers the
75+
// motivating case from issue #254: building SPL Token and SPL Token-2022
76+
// instructions in parallel must not require a process-wide mutex around
77+
// the package-level ProgramID. Run under `-race`.
78+
func TestInstruction_SetProgramID_ConcurrentBuildersDoNotRace(t *testing.T) {
79+
originalProgramID := ProgramID
80+
t.Cleanup(func() { ProgramID = originalProgramID })
81+
82+
src := solana.MustPublicKeyFromBase58("11111111111111111111111111111112")
83+
dst := solana.MustPublicKeyFromBase58("11111111111111111111111111111113")
84+
owner := solana.MustPublicKeyFromBase58("11111111111111111111111111111114")
85+
86+
const goroutines = 32
87+
var wg sync.WaitGroup
88+
wg.Add(goroutines)
89+
for i := 0; i < goroutines; i++ {
90+
i := i
91+
go func() {
92+
defer wg.Done()
93+
94+
// Alternate between the default program ID and the Token-2022
95+
// override so both code paths are exercised under -race.
96+
inst := NewTransferInstructionBuilder().
97+
SetAmount(uint64(i + 1)).
98+
SetSourceAccount(src).
99+
SetDestinationAccount(dst).
100+
SetOwnerAccount(owner).
101+
Build()
102+
103+
if i%2 == 0 {
104+
inst.SetProgramID(solana.Token2022ProgramID)
105+
if inst.ProgramID() != solana.Token2022ProgramID {
106+
t.Errorf("goroutine %d: expected Token2022ProgramID, got %s", i, inst.ProgramID())
107+
}
108+
} else {
109+
if inst.ProgramID() != ProgramID {
110+
t.Errorf("goroutine %d: expected package ProgramID, got %s", i, inst.ProgramID())
111+
}
112+
}
113+
}()
114+
}
115+
wg.Wait()
116+
117+
require.Equal(t, originalProgramID, ProgramID,
118+
"package ProgramID must be unchanged after concurrent builders")
119+
}

0 commit comments

Comments
 (0)