-
Notifications
You must be signed in to change notification settings - Fork 307
feat(sdk-coin-sol): add verifyTransaction validation for staking authorize intent #9463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,7 @@ import { | |
| Memo, | ||
| Nonce, | ||
| StakingActivate, | ||
| StakingAuthorize, | ||
| StakingAuthorizeParams, | ||
| StakingWithdraw, | ||
| TokenTransfer, | ||
|
|
@@ -540,6 +541,7 @@ export class Transaction extends BaseTransaction { | |
| const outputs: TransactionRecipient[] = []; | ||
| // Create a separate array for token enablements | ||
| const tokenEnablements: ITokenEnablement[] = []; | ||
| let stakingAuthorize: StakingAuthorizeParams | undefined = undefined; | ||
|
|
||
| for (const instruction of decodedInstructions) { | ||
| switch (instruction.type) { | ||
|
|
@@ -598,6 +600,36 @@ export class Transaction extends BaseTransaction { | |
| tokenAddress: ataInit.params.mintAddress, | ||
| }); | ||
| break; | ||
| case InstructionBuilderTypes.StakingAuthorize: { | ||
| const authorizeInstruction = instruction as StakingAuthorize; | ||
| // Neither instruction parser surfaces Solana's stakeAuthorizationType, so a | ||
| // Withdrawer-type authorize is identified by its custodian key: the standard | ||
| // parser surfaces it as newWithdrawAddress, the raw parser as custodianAddress. | ||
| // A standard authorize tx carries both a Staker and a Withdrawer instruction; | ||
| // the Withdrawer one wins because newWithdrawAddress is what verifyTransaction | ||
| // validates. Staker-only instructions must not populate the withdraw fields, | ||
| // otherwise a staker address would be compared against an intended withdraw key. | ||
| const isWithdrawerAuthorize = !!( | ||
| authorizeInstruction.params.newWithdrawAddress || authorizeInstruction.params.custodianAddress | ||
| ); | ||
| if (isWithdrawerAuthorize) { | ||
| stakingAuthorize = { | ||
| stakingAddress: authorizeInstruction.params.stakingAddress, | ||
| oldWithdrawAddress: authorizeInstruction.params.oldAuthorizeAddress, | ||
| newWithdrawAddress: authorizeInstruction.params.newAuthorizeAddress, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Your detection says 'this is a Withdrawer instruction because newWithdrawAddress is set,' but then you validate newAuthorizeAddress — what if they diverge? You'd be checking the wrong field and approving a malicious tx. |
||
| custodianAddress: authorizeInstruction.params.custodianAddress, | ||
| }; | ||
| } else if (!stakingAuthorize) { | ||
| stakingAuthorize = { | ||
| stakingAddress: authorizeInstruction.params.stakingAddress, | ||
| oldWithdrawAddress: '', | ||
| newWithdrawAddress: '', | ||
| oldStakingAuthorityAddress: authorizeInstruction.params.oldAuthorizeAddress, | ||
| newStakingAuthorityAddress: authorizeInstruction.params.newAuthorizeAddress, | ||
| }; | ||
| } | ||
| break; | ||
| } | ||
| case InstructionBuilderTypes.CustomInstruction: | ||
| // Custom instructions are arbitrary and cannot be explained | ||
| break; | ||
|
|
@@ -617,7 +649,7 @@ export class Transaction extends BaseTransaction { | |
| } | ||
| } | ||
|
|
||
| return this.getExplainedTransaction(outputAmount, outputs, memo, durableNonce, tokenEnablements); | ||
| return this.getExplainedTransaction(outputAmount, outputs, memo, durableNonce, tokenEnablements, stakingAuthorize); | ||
| } | ||
|
|
||
| private calculateFee(): string { | ||
|
|
@@ -638,7 +670,8 @@ export class Transaction extends BaseTransaction { | |
| outputs: TransactionRecipient[], | ||
| memo: undefined | string = undefined, | ||
| durableNonce: undefined | DurableNonceParams = undefined, | ||
| tokenEnablements: ITokenEnablement[] = [] | ||
| tokenEnablements: ITokenEnablement[] = [], | ||
| stakingAuthorize: StakingAuthorizeParams | undefined = undefined | ||
| ): TransactionExplanation { | ||
| const feeString = this.calculateFee(); | ||
|
|
||
|
|
@@ -674,6 +707,7 @@ export class Transaction extends BaseTransaction { | |
| blockhash: this.getNonce(), | ||
| durableNonce: durableNonce, | ||
| tokenEnablements: tokenEnablements, | ||
| ...(stakingAuthorize && { stakingAuthorize }), | ||
| }; | ||
|
|
||
| return explanation; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -571,6 +571,48 @@ export class Sol extends BaseCoin { | |
| } | ||
| } | ||
|
|
||
| const isStakingAuthorizeTx = | ||
| transaction.type === TransactionType.StakingAuthorize || | ||
| transaction.type === TransactionType.StakingAuthorizeRaw || | ||
| txParams.type === 'authorize'; | ||
| if (isStakingAuthorizeTx) { | ||
| const authorizeParams = explainedTx.stakingAuthorize; | ||
| if (!authorizeParams) { | ||
| throw new Error('StakingAuthorize transaction is missing stakingAuthorize explanation fields'); | ||
| } | ||
| // oldWithdrawAddress is '' for staker-only instructions (no Withdrawer authority change). | ||
| // Only validate when it is a non-empty string — an empty string indicates the instruction | ||
| // changes staker authority only, not withdrawer, so the wallet root check does not apply. | ||
| if ( | ||
| walletRootAddress && | ||
| authorizeParams.oldWithdrawAddress && | ||
| authorizeParams.oldWithdrawAddress !== walletRootAddress | ||
| ) { | ||
| throw new Error( | ||
| 'StakingAuthorize oldWithdrawAddress does not match wallet root address: expected ' + | ||
| walletRootAddress + | ||
| ' but got ' + | ||
| authorizeParams.oldWithdrawAddress | ||
| ); | ||
| } | ||
| if (txParams.newWithdrawPublicKey && authorizeParams.newWithdrawAddress !== txParams.newWithdrawPublicKey) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you trace where SolAuthorizeIntent is constructed and confirm whether newWithdrawPublicKey and stakeAccount are guaranteed present? |
||
| throw new Error( | ||
| 'StakingAuthorize newWithdrawAddress does not match intended newWithdrawPublicKey: expected ' + | ||
| txParams.newWithdrawPublicKey + | ||
| ' but got ' + | ||
| authorizeParams.newWithdrawAddress | ||
| ); | ||
| } | ||
| if (txParams.stakeAccount && authorizeParams.stakingAddress !== txParams.stakeAccount) { | ||
| throw new Error( | ||
| 'StakingAuthorize stakingAddress does not match intended stakeAccount: expected ' + | ||
| txParams.stakeAccount + | ||
| ' but got ' + | ||
| authorizeParams.stakingAddress | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| const isTokenEnablementTx = txParams.type === 'enabletoken'; | ||
| // users do not input recipients for consolidation requests as they are generated by the server | ||
| // Close-ATA txs do not populate explainedTx.outputs; recipients carry ATA addresses for intent only. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you confirm whether the non-WASM instruction decoder guarantees newWithdrawAddress is empty/unset for Staker-only authorize instructions? If not, we may need an explicit authorizeType field on the parsed instruction (like the WASM path has) rather than inferring from field presence.