Skip to content

Commit 1220edf

Browse files
mukeshspclaude
andcommitted
fix(sdk-coin-algo): surface rekey and block blind signing of rekeyed txs
- explainTransaction now includes reKeyTo in the explanation and displayOrder for pay, axfer and keyreg transactions carrying a rekey - toJson() emits reKeyTo so decoded rekeys are visible to all consumers - signTransaction throws InvalidTransactionError when the decoded transaction sets reKeyTo unless the caller passes allowReKeyTo: true - unit tests cover explain (pay/axfer) and sign (reject + opt-in) Ticket: CSHLD-2171 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6aef749 commit 1220edf

5 files changed

Lines changed: 218 additions & 4 deletions

File tree

‎modules/sdk-coin-algo/src/algo.ts‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
TransactionExplanation,
3030
TransactionRecipient,
3131
TransactionType,
32+
InvalidTransactionError,
3233
UnexpectedAddressError,
3334
VerifyAddressOptions,
3435
VerifyTransactionOptions,
@@ -74,6 +75,7 @@ export interface VerifyAlgoAddressOptions extends VerifyAddressOptions {
7475
export interface AlgoTransactionExplanation extends TransactionExplanation {
7576
memo?: string;
7677
type?: string | number;
78+
reKeyTo?: string;
7779
voteKey?: string;
7880
selectionKey?: string;
7981
voteFirst?: number;
@@ -91,6 +93,13 @@ export interface TransactionOperation {
9193
export interface SignTransactionOptions extends BaseSignTransactionOptions {
9294
txPrebuild: TransactionPrebuild;
9395
prv: string;
96+
/**
97+
* Explicit opt-in required to sign a transaction that sets `reKeyTo`: the exact rekey
98+
* address that was shown to and approved by the operator. A rekey hands spending
99+
* authority of the account to another address, so signing throws if the decoded
100+
* transaction rekeys to any other address.
101+
*/
102+
allowReKeyTo?: string;
94103
}
95104

96105
export interface TransactionPrebuild {
@@ -357,7 +366,7 @@ export class Algo extends BaseCoin {
357366
* @param params
358367
*/
359368
async explainTransaction(params: ExplainTransactionOptions): Promise<AlgoTransactionExplanation | undefined> {
360-
const txHex = params.txHex || (params.halfSigned && params.halfSigned.txHex);
369+
const txHex = (params.halfSigned && params.halfSigned.txHex) || params.txHex;
361370
if (!txHex || !params.feeInfo) {
362371
throw new Error('missing explain tx parameters');
363372
}
@@ -412,6 +421,13 @@ export class Algo extends BaseCoin {
412421
operations,
413422
};
414423

424+
// A rekey silently reassigns spending authority of the account, so it must never
425+
// be hidden from the explanation shown for approval.
426+
if (txJson.reKeyTo) {
427+
displayOrder.push('reKeyTo');
428+
explanationResult.reKeyTo = txJson.reKeyTo;
429+
}
430+
415431
if (txJson.tokenId) {
416432
explanationResult.tokenId = txJson.tokenId;
417433
}
@@ -432,7 +448,7 @@ export class Algo extends BaseCoin {
432448
'voteKeyDilution',
433449
];
434450

435-
return {
451+
const explanationResult: AlgoTransactionExplanation = {
436452
displayOrder,
437453
id: txJson.id,
438454
outputAmount: '0',
@@ -448,6 +464,14 @@ export class Algo extends BaseCoin {
448464
voteLast: txJson.voteLast,
449465
voteKeyDilution: txJson.voteKeyDilution,
450466
};
467+
468+
// A keyreg transaction can also carry a rekey; surface it like in the send case.
469+
if (txJson.reKeyTo) {
470+
displayOrder.push('reKeyTo');
471+
explanationResult.reKeyTo = txJson.reKeyTo;
472+
}
473+
474+
return explanationResult;
451475
}
452476
}
453477

@@ -550,6 +574,20 @@ export class Algo extends BaseCoin {
550574
*/
551575
async signTransaction(params: SignTransactionOptions): Promise<SignedTransaction> {
552576
const { txHex, signers, prv, isHalfSigned, numberSigners } = this.verifySignTransactionParams(params);
577+
// A rekey reassigns spending authority of the account; refuse to sign it unless the
578+
// caller passes the exact rekey address the operator approved. A bare opt-in would
579+
// also approve a different rekey target hidden in the bytes being signed.
580+
// Note: this guards Algo.signTransaction only; other signing entry points (e.g.
581+
// callers driving TransactionBuilder.sign directly) are not covered.
582+
const { txn } = Utils.decodeAlgoTxn(txHex);
583+
if (txn.reKeyTo) {
584+
const reKeyAddress = algosdk.encodeAddress(txn.reKeyTo.publicKey);
585+
if (params.allowReKeyTo !== reKeyAddress) {
586+
throw new InvalidTransactionError(
587+
`transaction rekeys the account to ${reKeyAddress}: refusing to sign unless the rekey is confirmed intentional (pass allowReKeyTo with the approved rekey address)`
588+
);
589+
}
590+
}
553591
const factory = this.getBuilder();
554592
const txBuilder = factory.from(txHex);
555593
txBuilder.numberOfRequiredSigners(numberSigners);

‎modules/sdk-coin-algo/src/lib/ifaces.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface TxData {
2020
genesisID: string;
2121
genesisHash: string | Buffer;
2222
closeRemainderTo?: string;
23+
reKeyTo?: string;
2324
nonParticipation?: boolean;
2425
txType?: string;
2526
tokenName?: string;

‎modules/sdk-coin-algo/src/lib/keyRegistrationBuilder.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ export class KeyRegistrationBuilder extends TransactionBuilder {
143143
undefined, // voteFirst param
144144
undefined, // voteLast param
145145
undefined, // voteKeyDilution param
146-
this.suggestedParams
146+
this.suggestedParams,
147+
this._reKeyTo
147148
);
148149
}
149150

@@ -157,7 +158,7 @@ export class KeyRegistrationBuilder extends TransactionBuilder {
157158
this._voteLast,
158159
this._voteKeyDilution,
159160
this.suggestedParams,
160-
undefined, // reKeyTo param
161+
this._reKeyTo,
161162
undefined, // nonParticipation param
162163
this._stateProofKey
163164
);

‎modules/sdk-coin-algo/src/lib/transaction.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,9 @@ export class Transaction extends BaseTransaction {
252252
if (this._algoTransaction.closeRemainderTo) {
253253
result.closeRemainderTo = algosdk.encodeAddress(this._algoTransaction.closeRemainderTo.publicKey);
254254
}
255+
if (this._algoTransaction.reKeyTo) {
256+
result.reKeyTo = algosdk.encodeAddress(this._algoTransaction.reKeyTo.publicKey);
257+
}
255258
if (this.type === TransactionType.Send) {
256259
result.to = algosdk.encodeAddress(this._algoTransaction.to.publicKey);
257260
result.amount = this._algoTransaction.amount.toString();

‎modules/sdk-coin-algo/test/unit/algo.ts‎

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,52 @@ describe('ALGO:', function () {
233233
explain.outputs[0].amount.should.equal(amount.toString());
234234
explain.outputs[0].address.should.equal(destination);
235235
});
236+
237+
it('should surface the rekey target when explaining a transfer transaction that rekeys the account', async function () {
238+
const sender = AlgoResources.accounts.account1.address;
239+
const destination = AlgoResources.accounts.account2.address;
240+
const rekeyAddress = AlgoResources.accounts.account3.address;
241+
const txBuilder = buildBaseTransferTransaction({ sender, destination, amount: 10000 });
242+
txBuilder.reKeyTo({ address: rekeyAddress });
243+
const unsignedTransaction = await txBuilder.build();
244+
const unsignedHex = Buffer.from(unsignedTransaction.toBroadcastFormat()).toString('hex');
245+
const explain = await basecoin.explainTransaction({
246+
txHex: unsignedHex,
247+
feeInfo: { fee: '1000' },
248+
});
249+
explain.reKeyTo.should.equal(rekeyAddress);
250+
explain.displayOrder.should.containEql('reKeyTo');
251+
explain.outputs[0].address.should.equal(destination);
252+
});
253+
254+
it('should explain the halfSigned transaction when both txHex and halfSigned txHex are provided', async function () {
255+
const sender = AlgoResources.accounts.account1.address;
256+
const destination = AlgoResources.accounts.account2.address;
257+
const rekeyAddress = AlgoResources.accounts.account3.address;
258+
259+
// plain unsigned payment without a rekey, used as the misleading txHex
260+
const plainHex = Buffer.from(
261+
(await buildBaseTransferTransaction({ sender, destination, amount: 10000 }).build()).toBroadcastFormat()
262+
).toString('hex');
263+
264+
// half-signed payment that rekeys the account, used as the halfSigned txHex
265+
const rekeyedBuilder = buildBaseTransferTransaction({ sender, destination, amount: 10000 });
266+
rekeyedBuilder.reKeyTo({ address: rekeyAddress });
267+
rekeyedBuilder.numberOfSigners(2);
268+
rekeyedBuilder.setSigners([AlgoResources.accounts.account1.address, AlgoResources.accounts.account3.address]);
269+
rekeyedBuilder.sign({ key: AlgoResources.accounts.account1.prvKey });
270+
const halfSignedHex = Buffer.from((await rekeyedBuilder.build()).toBroadcastFormat()).toString('hex');
271+
272+
// signTransaction signs the halfSigned hex when both are present, so the
273+
// explanation must come from the same bytes, not from txHex
274+
const explain = await basecoin.explainTransaction({
275+
txHex: plainHex,
276+
halfSigned: { txHex: halfSignedHex },
277+
feeInfo: { fee: '1000' },
278+
});
279+
explain.reKeyTo.should.equal(rekeyAddress);
280+
explain.displayOrder.should.containEql('reKeyTo');
281+
});
236282
});
237283

238284
describe('Asset Transfer Builder: ', () => {
@@ -349,6 +395,23 @@ describe('ALGO:', function () {
349395
explain.outputs[0].address.should.equal(destination);
350396
explain.tokenId.should.equal(1);
351397
});
398+
399+
it('should surface the rekey target when explaining an asset transfer transaction that rekeys the account', async function () {
400+
const sender = AlgoResources.accounts.account1.address;
401+
const destination = AlgoResources.accounts.account2.address;
402+
const rekeyAddress = AlgoResources.accounts.account3.address;
403+
const txBuilder = buildBaseAssetTransferTransaction({ sender, destination, amount: 10000, tokenId: 1 });
404+
txBuilder.reKeyTo({ address: rekeyAddress });
405+
const unsignedTransaction = await txBuilder.build();
406+
const unsignedHex = Buffer.from(unsignedTransaction.toBroadcastFormat()).toString('hex');
407+
const explain = await basecoin.explainTransaction({
408+
txHex: unsignedHex,
409+
feeInfo: { fee: '1000' },
410+
});
411+
explain.reKeyTo.should.equal(rekeyAddress);
412+
explain.displayOrder.should.containEql('reKeyTo');
413+
explain.outputs[0].address.should.equal(destination);
414+
});
352415
});
353416

354417
describe('Wallet Init Builder: ', () => {
@@ -538,6 +601,21 @@ describe('ALGO:', function () {
538601
explain.voteLast.should.equal(100);
539602
explain.voteKeyDilution.should.equal(9);
540603
});
604+
605+
it('should surface the rekey target when explaining a keyreg transaction that rekeys the account', async function () {
606+
const sender = AlgoResources.accounts.account1;
607+
const rekeyAddress = AlgoResources.accounts.account3.address;
608+
const txBuilder = buildBaseKeyRegTransaction({ sender });
609+
txBuilder.reKeyTo({ address: rekeyAddress });
610+
const unsignedTransaction = await txBuilder.build();
611+
const unsignedHex = Buffer.from(unsignedTransaction.toBroadcastFormat()).toString('hex');
612+
const explain = await basecoin.explainTransaction({
613+
txHex: unsignedHex,
614+
feeInfo: { fee: '1000' },
615+
});
616+
explain.reKeyTo.should.equal(rekeyAddress);
617+
explain.displayOrder.should.containEql('reKeyTo');
618+
});
541619
});
542620
describe('Sign transaction', () => {
543621
it('should sign transaction', async function () {
@@ -648,6 +726,99 @@ describe('ALGO:', function () {
648726
});
649727
signed.txHex.should.equal(AlgoResources.rawTx.transfer.multisig);
650728
});
729+
730+
it('should refuse to sign a transaction that rekeys the account', async function () {
731+
const factory = new TransactionBuilderFactory(coins.get('algo'));
732+
const txBuilder = factory
733+
.getTransferBuilder()
734+
.sender({ address: AlgoResources.accounts.account1.address })
735+
.to({ address: AlgoResources.accounts.account2.address })
736+
.amount(10000)
737+
.isFlatFee(true)
738+
.fee({ fee: '1000' })
739+
.firstRound(1)
740+
.lastRound(100)
741+
.testnet();
742+
txBuilder.reKeyTo({ address: AlgoResources.accounts.account3.address });
743+
const unsignedTx = await txBuilder.build();
744+
const unsignedHex = Buffer.from(unsignedTx.toBroadcastFormat()).toString('hex');
745+
746+
await basecoin
747+
.signTransaction({
748+
txPrebuild: {
749+
txHex: unsignedHex,
750+
keys: [AlgoResources.accounts.account1.pubKey.toString('hex')],
751+
addressVersion: 1,
752+
},
753+
prv: AlgoResources.accounts.account1.prvKey,
754+
})
755+
.should.be.rejectedWith(
756+
new RegExp(
757+
`transaction rekeys the account to ${AlgoResources.accounts.account3.address}: refusing to sign unless the rekey is confirmed intentional`
758+
)
759+
);
760+
});
761+
762+
it('should sign a transaction that rekeys the account when the rekey is explicitly allowed', async function () {
763+
const factory = new TransactionBuilderFactory(coins.get('algo'));
764+
const txBuilder = factory
765+
.getTransferBuilder()
766+
.sender({ address: AlgoResources.accounts.account1.address })
767+
.to({ address: AlgoResources.accounts.account2.address })
768+
.amount(10000)
769+
.isFlatFee(true)
770+
.fee({ fee: '1000' })
771+
.firstRound(1)
772+
.lastRound(100)
773+
.testnet();
774+
txBuilder.reKeyTo({ address: AlgoResources.accounts.account3.address });
775+
const unsignedTx = await txBuilder.build();
776+
const unsignedHex = Buffer.from(unsignedTx.toBroadcastFormat()).toString('hex');
777+
778+
const signed = await basecoin.signTransaction({
779+
txPrebuild: {
780+
txHex: unsignedHex,
781+
keys: [AlgoResources.accounts.account1.pubKey.toString('hex')],
782+
addressVersion: 1,
783+
},
784+
prv: AlgoResources.accounts.account1.prvKey,
785+
allowReKeyTo: AlgoResources.accounts.account3.address,
786+
});
787+
assert.strictEqual(typeof signed.txHex, 'string');
788+
});
789+
790+
it('should refuse to sign a rekeyed transaction when the approved rekey address does not match', async function () {
791+
const factory = new TransactionBuilderFactory(coins.get('algo'));
792+
const txBuilder = factory
793+
.getTransferBuilder()
794+
.sender({ address: AlgoResources.accounts.account1.address })
795+
.to({ address: AlgoResources.accounts.account2.address })
796+
.amount(10000)
797+
.isFlatFee(true)
798+
.fee({ fee: '1000' })
799+
.firstRound(1)
800+
.lastRound(100)
801+
.testnet();
802+
txBuilder.reKeyTo({ address: AlgoResources.accounts.account3.address });
803+
const unsignedTx = await txBuilder.build();
804+
const unsignedHex = Buffer.from(unsignedTx.toBroadcastFormat()).toString('hex');
805+
806+
await basecoin
807+
.signTransaction({
808+
txPrebuild: {
809+
txHex: unsignedHex,
810+
keys: [AlgoResources.accounts.account1.pubKey.toString('hex')],
811+
addressVersion: 1,
812+
},
813+
prv: AlgoResources.accounts.account1.prvKey,
814+
allowReKeyTo: AlgoResources.accounts.account2.address,
815+
})
816+
.should.be.rejectedWith(
817+
new RegExp(
818+
`transaction rekeys the account to ${AlgoResources.accounts.account3.address}: refusing to sign unless the rekey is confirmed intentional`
819+
)
820+
);
821+
});
651822
});
652823

653824
describe('Sign message', () => {

0 commit comments

Comments
 (0)