Oracles
wstGBP has two on-chain price surfaces. The pip is the protocol’s price source: it is
updated off-chain on a regular cadence (currently weekly), and everything that quotes
mint and redeem reads it. On top of it sits an ownerless Chainlink-compatible aggregator
that publishes the redemption price for lending markets and any other
AggregatorV3Interface consumer. All addresses are Ethereum mainnet (chainId 1) and
verifiable on a block explorer.
| Contract | Address | Role |
|---|---|---|
pip (proxy) | 0x6A79dCe61A12aa4b75449e0B03746260765D07dF | NAV price source and pause authority |
pip implementation | 0x44BFEB1110bA6091034DBaAb450eF1e7469fF072 | Logic and event ABI (MaseerPrice behind MaseerProxy) |
| wstGBP/tGBP feed | 0xF7493C2739c2b1bF5E6bB0e5b16A265Ed0B400B0 | Chainlink-compatible aggregator (WsgemAggregatorV3), 8 decimals |
The NAV oracle (pip)
The pip’s entire price surface is one function: read() returns (uint256), denominated
in gem (tGBP) units per 1e18 wstGBP. tGBP is 18-decimal, so for wstGBP the value is WAD;
it is the same number the token serves as navprice() (see the
Contract Reference). The feed launched at
exactly 1e18 and rises as the NAV is published.
The pip stores exactly one value, the current price, and serves it to every quoting path.
Update history is carried by the Poke event: every write, including a pause, emits
Poke(price, block.timestamp). Round ids and stamped history live on the
Chainlink-compatible feed below.
Pause is expressed in the price itself. pause() publishes 0, and the surfaces above
the pip translate that into a revert: the token’s quoting functions revert
InvalidPrice() and the feed below reverts OraclePaused().
| Function | Returns | Notes |
|---|---|---|
read() | uint256 | The current price, gem units per 1e18 wstGBP. 0 while paused. |
paused() | bool | True when the stored price is 0. |
name() | string | Feed label. |
decimals() | uint8 | Informational only; see the note below. |
decimals() on the pip is an informational value set by governance, validated only to
be at most 18. It is never used to scale the published price, which is always gem
units per 1e18 wstGBP. The subgraph mirrors the value as CurrentPrice.priceDecimals.
For quoting, read navprice(), mintcost(), or burncost() on the token, or use the
Chainlink-compatible feed below. A direct pip read returns the same NAV with no spread
applied, and paused() reports the pause state in a single call.
Governance and updates
The NAV is published by allowlisted poster addresses calling poke(uint256). The
allowlist is managed with kiss(address) (grant) and diss(address) (revoke), and is
readable via bud(address) returns (uint256) (1 when allowlisted). Governance wards,
managed with rely(address) / deny(address) and readable via
wards(address) returns (uint256), can pause() the feed and set parameters with
file(bytes32 what, bytes32 data) for "price", "name", and "decimals". These are
governance functions; every call is observable on-chain through the events below.
Events (pip)
event Poke(uint256 indexed price, uint256 indexed timestamp);
event Kiss(address indexed usr);
event Diss(address indexed usr);
event File(bytes32 indexed what, bytes32 data);Like the other proxy modules, events are emitted through the proxy address while the
event ABI lives on the implementation. The subgraph indexes them as PriceUpdate,
FeedReader, and PriceFileEvent entities (see
Data & Analytics).
The Chainlink-compatible feed (wstGBP/tGBP)
WsgemAggregatorV3 at 0xF7493C2739c2b1bF5E6bB0e5b16A265Ed0B400B0 publishes the
wstGBP/tGBP redemption price through the standard Chainlink AggregatorV3Interface,
plus the older V2 aggregator surface. It was deployed at block 25677571 (2026-08-03) in
transaction 0x8c3dc1fe8dffdb28ad825a72da369e22da80ed607355c79ceacd1fa7a3f96ac8.
decimals() returns 8, version() returns 1, and description() returns
"wstGBP/tGBP".
The answer is the redemption price of one wstGBP in tGBP at 8 decimals: the token’s
burncost() divided by 1e10, floored. It is not the raw NAV. burncost() is NAV net of
the 25 bps redemption spread (burncost = ceil(navprice * 9975 / 10000)), so the answer
sits slightly below the headline rate and collateral is never valued above what
redemption actually pays. The answer would shift if governance changed the spread. As a
worked example, an answer of 100486157 means one wstGBP redeems for 1.00486157 tGBP.
The feed is ownerless. It has no owner, no ward, no setter, and no upgrade path; every
parameter is immutable. The only mutable state is the round history, written by the
permissionless poke() described below. Changing anything would mean deploying a new
feed at a new address.
Internally the feed makes two separate reads on every quote: the pip first, as the pause
authority, then burncost() on the token, which resolves through the market gate behind
its own upgradeable proxy.
Feed surface
| Function | Returns | Notes |
|---|---|---|
latestRoundData() | (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound) | answer is live; the timestamps come from the last stamped round. Reverts while paused. |
getRoundData(uint80 roundId) | (uint80, int256, uint256, uint256, uint80) | Pure history, never live. Reverts InvalidRound() for round 0 or beyond the latest. |
latestAnswer() | int256 | Live answer; equals latestRoundData().answer. |
currentAnswer() | int256 | Alias for latestAnswer(). |
latestTimestamp() | uint256 | Timestamp of the latest stamped round; the live answer can move before the next poke() stamps it. Does not read the feed, so it stays readable through a pause. |
latestRound() | uint256 | Highest stamped round id. |
getAnswer(uint256 roundId) | int256 | Historical answer by round. |
getTimestamp(uint256 roundId) | uint256 | Historical timestamp by round. |
paused() | bool | True while the pip reads 0. Never reverts. |
pokeable() | bool | Whether poke() would stamp a new round right now. |
poke() | uint80 roundId | Permissionless; stamps the current answer into history. Reverts NoChange() if the 8-decimal answer is unchanged. |
WSGEM() / PIP() / GEM() | address | The wired token, pip, and underlying. |
SRC_DECIMALS() | uint8 | Decimals of the source quote (18 for wstGBP). |
Round semantics
Rounds are dense from 1; round 0 never exists and history is unbounded. Rounds stamp on
change: a new round is written when the 8-decimal answer moves, not on a fixed interval.
startedAt always equals updatedAt, and answeredInRound always equals roundId.
latestRoundData().answer is read live, while getRoundData(latestRound()).answer is the
stored historical record. Between a rate move and the next poke(), the two can disagree.
Anyone can call poke() to re-sync them, and pokeable() says whether doing so would
change anything.
Staleness
updatedAt can legitimately be a week or more old while the feed is healthy. The NAV
publishes on a regular cadence (currently weekly) and rounds only stamp on a change,
so a stale updatedAt does not imply a stale answer: the answer is read live on every
call. Do not enforce a heartbeat tighter than about two weeks. Frameworks that
hard-code a 1-24 hour heartbeat preset will trip during normal operation.
For monitoring, useful alarms are: pokeable() staying true for more than a day (a
poke is due but nobody has called it), paused() returning true (the pip is paused),
and latestRoundData() reverting while paused() returns false (the feed reverting
InvalidPrice() or AnswerOverflow() rather than OraclePaused()).
Reverts, not zeros
The feed never serves an answer of 0. Where a price is unavailable, it reverts:
| Error | Thrown when |
|---|---|
OraclePaused() | The pip is paused (read() is 0). Treat as feed down. |
InvalidPrice() | The pip is live but the quote is zero or rescales to nothing. |
AnswerOverflow() | The 8-decimal quote exceeds 2**191 - 1. |
InvalidRound() | getRoundData, getAnswer, or getTimestamp for round 0 or beyond the latest. |
NoChange() | poke() while the 8-decimal answer has not moved. |
During a pause, latestRoundData(), latestAnswer(), and currentAnswer() revert,
while getRoundData(), getAnswer(), getTimestamp(), latestTimestamp(),
latestRound(), and paused() stay readable. This InvalidPrice() is the feed’s own
error; the token throws an error with the same name (see the
Contract Reference) under its own conditions.
Events (feed)
event AnswerUpdated(int256 indexed current, uint256 indexed roundId, uint256 updatedAt);
event NewRound(uint256 indexed roundId, address indexed startedBy, uint256 startedAt);The signatures, including indexed parameters, are identical to Chainlink’s
AnswerUpdated and NewRound, so Chainlink ABIs decode them without modification.
Reading the feed
import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
const feedAbi = [
{ type: 'function', name: 'latestRoundData', stateMutability: 'view', inputs: [],
outputs: [
{ name: 'roundId', type: 'uint80' },
{ name: 'answer', type: 'int256' },
{ name: 'startedAt', type: 'uint256' },
{ name: 'updatedAt', type: 'uint256' },
{ name: 'answeredInRound', type: 'uint80' },
] },
{ type: 'function', name: 'decimals', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] },
] as const
const client = createPublicClient({ chain: mainnet, transport: http() })
const [roundId, answer, startedAt, updatedAt] = await client.readContract({
address: '0xF7493C2739c2b1bF5E6bB0e5b16A265Ed0B400B0',
abi: feedAbi,
functionName: 'latestRoundData',
})
// The feed reverts rather than serving 0, so this branch is unreachable against the wired feed.
if (answer <= 0n) throw new Error('invalid answer')
// Size the staleness bound to the NAV cadence (weekly), not to hours.
const age = BigInt(Math.floor(Date.now() / 1000)) - updatedAt
if (age > 14n * 24n * 60n * 60n) throw new Error('stale feed')Verify against chain
The invariant to check: the feed’s answer equals the token’s burncost() divided by
1e10, floored.
AGG=0xF7493C2739c2b1bF5E6bB0e5b16A265Ed0B400B0
WSGEM=0x57C3571f10767E49C9d7b60feb6c67804783B7aE
PIP=0x6A79dCe61A12aa4b75449e0B03746260765D07dF
cast call $AGG "latestRoundData()(uint80,int256,uint256,uint256,uint80)" --rpc-url $ETH_RPC_URL
cast call $WSGEM "burncost()(uint256)" --rpc-url $ETH_RPC_URL
cast call $PIP "read()(uint256)" --rpc-url $ETH_RPC_URLSource
The aggregator is WsgemAggregatorV3 in
Arb-Capital/wsgem-oracles (MIT). The pip
implementation is MaseerPrice in
maseer-finance/maseer-one . Both are
verified on Etherscan at the addresses above.